├── .github └── workflows │ └── lint.yml ├── .gitignore ├── .luarc.json ├── LICENSE ├── README.md ├── addons ├── README.md ├── apache-browser.lua ├── favourites.lua ├── ftp-browser.lua ├── m3u-browser.lua ├── powershell.lua ├── root.lua └── url-decode.lua ├── docs ├── addons.md ├── custom-keybinds.md ├── file-browser-keybinds.json └── file_browser.conf ├── main.lua ├── modules ├── addons.lua ├── addons │ ├── cache.lua │ ├── file.lua │ ├── find.lua │ ├── home-label.lua │ ├── last-opened-directory.lua │ ├── ls.lua │ ├── root.lua │ ├── windir.lua │ └── winroot.lua ├── apis │ ├── fb.lua │ ├── parse-state.lua │ └── parser.lua ├── ass.lua ├── controls.lua ├── defs │ ├── file-browser.lua │ ├── keybind.lua │ ├── list.lua │ ├── mp │ │ ├── defaults.lua │ │ ├── input.lua │ │ ├── msg.lua │ │ ├── options.lua │ │ └── utils.lua │ ├── parser.lua │ ├── state.lua │ └── user-input.lua ├── globals.lua ├── keybinds.lua ├── navigation │ ├── cursor.lua │ ├── directory-movement.lua │ └── scanning.lua ├── observers.lua ├── options.lua ├── playlist.lua ├── script-messages.lua ├── setup.lua └── utils.lua └── screenshots ├── bunny.png └── front.png /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: [ "master" ] 5 | pull_request: 6 | branches: [ "master" ] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | Build_LuaLS: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | luals_version: ${{ steps.get-luaLS-version.outputs.version }} 16 | steps: 17 | - name: Get LuaLS version 18 | id: get-luaLS-version 19 | run: | 20 | version=$(curl https://api.github.com/repos/LuaLS/lua-language-server/releases/latest | jq -r '.tag_name') 21 | echo $version 22 | echo "version=$version" >> $GITHUB_OUTPUT 23 | shell: bash 24 | 25 | - name: Cache Lua Language Server 26 | uses: actions/cache@v4 27 | id: cache 28 | with: 29 | key: ${{ steps.get-luaLS-version.outputs.version }} 30 | path: ./luals 31 | 32 | - name: Download LuaLS 33 | uses: robinraju/release-downloader@v1 34 | if: steps.cache.outputs.cache-hit != 'true' 35 | with: 36 | repository: LuaLS/lua-language-server 37 | tag: ${{ steps.get-luaLS-version.outputs.version }} 38 | fileName: '*-linux-x64.tar.gz' 39 | extract: true 40 | out-file-path: ./luals 41 | 42 | Lint_LuaJit: 43 | needs: Build_LuaLS 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | with: 48 | path: ./file-browser 49 | - name: Cache Lua Language Server 50 | uses: actions/cache@v4 51 | id: cache 52 | with: 53 | key: ${{ needs.Build_LuaLS.outputs.luals_version }} 54 | path: ./luals 55 | - name: Run LuaLS 56 | run: | 57 | jq '."runtime.version" = "LuaJIT"' ./file-browser/.luarc.json > tmp.json 58 | mv tmp.json ./file-browser/.luarc.json 59 | ./luals/bin/lua-language-server --check=./file-browser 60 | 61 | Lint_Lua51: 62 | needs: Build_LuaLS 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v4 66 | with: 67 | path: ./file-browser 68 | - name: Cache Lua Language Server 69 | uses: actions/cache@v4 70 | id: cache 71 | with: 72 | key: ${{ needs.Build_LuaLS.outputs.luals_version }} 73 | path: ./luals 74 | - name: Run LuaLS 75 | run: | 76 | jq '."runtime.version" = "Lua 5.1"' ./file-browser/.luarc.json > tmp.json 77 | mv tmp.json ./file-browser/.luarc.json 78 | ./luals/bin/lua-language-server --check=./file-browser 79 | 80 | Lint_Lua52: 81 | needs: Build_LuaLS 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | path: ./file-browser 87 | - name: Cache Lua Language Server 88 | uses: actions/cache@v4 89 | id: cache 90 | with: 91 | key: ${{ needs.Build_LuaLS.outputs.luals_version }} 92 | path: ./luals 93 | - name: Run LuaLS 94 | run: | 95 | jq '."runtime.version" = "Lua 5.2"' ./file-browser/.luarc.json > tmp.json 96 | mv tmp.json ./file-browser/.luarc.json 97 | ./luals/bin/lua-language-server --check=./file-browser 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime.version": "LuaJIT", 3 | "runtime.path": [ 4 | "?.lua", 5 | "?/init.lua", 6 | "../../script-modules/?.lua" 7 | ], 8 | "diagnostics.workspaceDelay": 1000, 9 | "diagnostics.groupFileStatus": { 10 | "await": "Any", 11 | "luadoc": "Any", 12 | "type-check": "Any", 13 | "unbalanced": "Any", 14 | "strong": "Any" 15 | }, 16 | "diagnostics.groupSeverity": { 17 | "await": "Warning", 18 | "strong": "Warning" 19 | }, 20 | "diagnostics.severity": { 21 | "missing-return": "Error!", 22 | "missing-return-value": "Error!", 23 | "cast-type-mismatch": "Error!", 24 | "assign-type-mismatch": "Error!", 25 | "global-element": "Warning!" 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oscar Manglaras 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-file-browser 2 | 3 | ![cover](screenshots/bunny.png) 4 | 5 | This script allows users to browse and open files and folders entirely from within mpv. The script uses nothing outside the mpv API, so should work identically on all platforms. The browser can move up and down directories, start playing files and folders, or add them to the queue. 6 | 7 | By default only file types compatible with mpv will be shown, but this can be changed in the config file. 8 | 9 | This script requires at least **mpv v0.33**. 10 | 11 | Originally, file-browser worked with versions of mpv going back to 12 | v0.31, you can find those older versions of file-browser in the 13 | [mpv-v0.31 branch](https://github.com/CogentRedTester/mpv-file-browser/tree/mpv-v0.31). 14 | That branch will no longer be receiving any feature updates, 15 | but I will try to fix any bugs that are reported on the issue 16 | tracker. 17 | 18 | ## Installation 19 | 20 | ### Basic 21 | 22 | Clone this git repository into the mpv `~~/scripts` directory and 23 | change the name of the folder from `mpv-file-browser` to `file-browser`. 24 | You can then pull to receive updates. 25 | Alternatively, you can download the zip and extract the contents to `~~/scripts/file-browser`. 26 | `~~/` is the mpv config directory which is typically `~/.config/mpv/` on linux and `%APPDATA%/mpv/` on windows. 27 | 28 | ### Configuration 29 | 30 | Create a `file_browser.conf` file in the `~~/script-opts/` directory to configure the script. 31 | See [docs/file_browser.conf](docs/file_browser.conf) for the full list of options and their default values. 32 | The [`root` option](#root-directory) may be worth tweaking for your system. 33 | 34 | ### Addons 35 | 36 | To use [addons](addons/README.md) place addon files in the `~~/script-modules/file-browser-addons/` directory. 37 | 38 | ### Custom Keybinds 39 | To setup [custom keybinds](docs/custom-keybinds.md) create a `~~/script-opts/file-browser-keybinds.json` file. 40 | Do **not** copy the `file-browser-keybinds.json` file 41 | stored in this repository, that file is a collection of random examples, many of which are for completely different 42 | operating systems. Use them and the [docs](docs/custom-keybinds.md) to create your own collection of keybinds. 43 | 44 | ### File Structure 45 | 46 |
47 | Expected directory tree (basic): 48 | 49 | ``` 50 | ~~/ 51 | ├── script-opts 52 | │   └── file_browser.conf 53 | └── scripts 54 |    └── file-browser 55 |       ├── addons/ 56 |       ├── docs/ 57 |       ├── modules/ 58 |       ├── screenshots/ 59 |       ├── LICENSE 60 |       ├── main.lua 61 |       └── README.md 62 | ``` 63 |
64 | 65 |
66 | Expected directory tree (full): 67 | 68 | ``` 69 | ~~/ 70 | ├── script-modules 71 | │   └── file-browser-addons 72 | │   ├── addon1.lua 73 | │   ├── addon2.lua 74 | │   └── etc.lua 75 | ├── script-opts 76 | │   ├── file_browser.conf 77 | │   └── file-browser-keybinds.json 78 | └── scripts 79 |    └── file-browser 80 |       ├── addons/ 81 |       ├── docs/ 82 |       ├── modules/ 83 |       ├── screenshots/ 84 |       ├── LICENSE 85 |       ├── main.lua 86 |       └── README.md 87 | ``` 88 |
89 | 90 | ## Keybinds 91 | 92 | The following keybinds are set by default 93 | 94 | | Key | Name | Description | 95 | |-------------|----------------------------------|-------------------------------------------------------------------------------| 96 | | MENU | browse-files | toggles the browser | 97 | | Ctrl+o | open-browser | opens the browser | 98 | | Alt+o | browse-directory/get-user-input | opens a dialogue box to type in a directory - requires [mpv-user-input](#mpv-user-input) when mpv < v0.38 | 99 | 100 | The following dynamic keybinds are only set while the browser is open: 101 | 102 | | Key | Name | Description | 103 | |-------------|---------------|-------------------------------------------------------------------------------| 104 | | ESC | close | closes the browser or clears the selection | 105 | | ENTER | play | plays the currently selected file or folder | 106 | | Shift+ENTER | play_append | appends the current file or folder to the playlist | 107 | | Alt+ENTER | play_autoload | loads playlist entries before and after the selected file (like autoload.lua) | 108 | | RIGHT | down_dir | enter the currently selected directory | 109 | | LEFT | up_dir | move to the parent directory | 110 | | DOWN | scroll_down | move selector down the list | 111 | | UP | scroll_up | move selector up the list | 112 | | PGDWN | page_down | move selector down the list by a page (the num_entries option) | 113 | | PGUP | page_up | move selector up the list by a page (the num_entries option) | 114 | | Shift+PGDWN | list_bottom | move selector to the bottom of the list | 115 | | Shift+PGUP | list_top | move selector to the top of the list | 116 | | HOME | goto_current | move to the directory of the currently playing file | 117 | | Shift+HOME | goto_root | move to the root directory | 118 | | Alt+LEFT | history_back | move to previously open directory | 119 | | Alt+RIGHT | history_forward| move forwards again in history to the next directory | 120 | | Ctrl+r | reload | reload current directory | 121 | | Ctrl+Shift+r| cache/clear | clears the directory cache (disabled by default) | 122 | | s | select_mode | toggles multiselect mode | 123 | | S | select_item | toggles selection for the current item | 124 | | Ctrl+a | select_all | select all items in the current directory | 125 | | Ctrl+f | find/find | Opens a text input to search the contents of the folder - requires [mpv-user-input](#mpv-user-input) when mpv < v0.38| 126 | | Ctrl+F | find/find_advanced| Allows using [Lua Patterns](https://www.lua.org/manual/5.1/manual.html#5.4.1) in the search input| 127 | | n | find/next | Jumps to the next matching entry for the latest search term | 128 | | N | find/prev | Jumps to the previous matching entry for the latest search term | 129 | 130 | When attempting to play or append a subtitle file the script will instead load the subtitle track into the existing video. 131 | 132 | The behaviour of the autoload keybind can be reversed with the `autoload` script-opt. 133 | By default the playlist will only be autoloaded if `Alt+ENTER` is used on a single file, however when the option is switched autoload will always be used on single files *unless* `Alt+ENTER` is used. Using autoload on a directory, or while appending an item, will not work. 134 | 135 | ## Root Directory 136 | 137 | To accomodate for both windows and linux this script has its own virtual root directory where drives and file folders can be manually added. The root directory can only contain folders. 138 | 139 | The root directory is set using the `root` option, which is a comma separated list of directories. Entries are sent through mpv's `expand-path` command. By default `~/` and `C:/` are set on Windows 140 | and `~/` and `/` are set on non-Windows systems. 141 | Extra locations can be added manually, for example, my Windows root looks like: 142 | 143 | `root=~/,C:/,D:/,E:/,Z:/` 144 | 145 | ## Multi-Select 146 | 147 | By default file-browser only opens/appends the single item that the cursor has selected. 148 | However, using the `s` keybinds specified above, it is possible to select multiple items to open all at once. Selected items are shown in a different colour to the cursor. 149 | When in multiselect mode the cursor changes colour and scrolling up and down the list will drag the current selection. If the original item was unselected, then dragging will select items, if the original item was selected, then dragging will unselect items. 150 | 151 | When multiple items are selected using the open or append commands all selected files will be added to the playlist in the order they appear on the screen. 152 | The currently selected (with the cursor) file will be ignored, instead the first multi-selected item in the folder will follow replace/append behaviour as normal, and following selected items will be appended to the playlist afterwards in the order that they appear on the screen. 153 | 154 | ## Custom Keybinds 155 | 156 | File-browser also supports custom keybinds. These keybinds send normal input commands, but the script will substitute characters in the command strings for specific values depending on the currently open directory, and currently selected item. 157 | This allows for a wide range of customised behaviour, such as loading additional audio tracks from the browser, or copying the path of the selected item to the clipboard. 158 | 159 | To see how to enable and use custom keybinds, see [custom-keybinds.md](docs/custom-keybinds.md). 160 | 161 | ## Add-ons 162 | 163 | Add-ons are ways to add extra features to file-browser, for example adding support for network file servers like ftp, or implementing virtual directories in the root like recently opened files. 164 | They can be enabled by setting `addon` script-opt to yes, and placing the addon file into the `~~/script-modules/file-browser-addons/` directory. 165 | 166 | For a list of existing addons see the [wiki](https://github.com/CogentRedTester/mpv-file-browser/wiki/Addon-List). 167 | For instructions on writing your own addons see [addons.md](docs/addons.md). 168 | 169 | ## Script Messages 170 | 171 | File-browser supports a small number of script messages that allow the user or other scripts to talk with the browser. 172 | 173 | ### `browse-directory` 174 | 175 | `script-message browse-directory [directory]` 176 | 177 | Opens the given directory in the browser. If the browser is currently closed it will be opened. 178 | 179 | ### `get-directory-contents` 180 | 181 | `script-message get-directory-contents [directory] [response-string]` 182 | 183 | Reads the given directory, and sends the resulting tables to the specified script-message in the format: 184 | 185 | `script-message [response-string] [list] [opts]` 186 | 187 | The [list](docs/addons.md#the-list-array) 188 | and [opts](docs/addons.md#the-opts-table) 189 | tables are formatted as json strings through the `mp.utils.format_json` function. 190 | See [addons.md](docs/addons.md) for how the tables are structured, and what each field means. 191 | The API_VERSION field of the `opts` table refers to what version of the addon API file browser is using. 192 | The `response-string` refers to an arbitrary script-message that the tables should be sent to. 193 | 194 | This script-message allows other scripts to utilise file-browser's directory parsing capabilities, as well as those of the file-browser addons. 195 | 196 | ## Conditional Auto-Profiles 197 | 198 | file-browser provides a property that can be used with [conditional auto-profiles](https://mpv.io/manual/master/#conditional-auto-profiles) 199 | to detect when the browser is open. 200 | On mpv v0.36+ you should use the `user-data` property with the `file_browser/open` boolean. 201 | 202 | Here is an example of an auto-profile that hides the OSC logo when using file-browser in an idle window: 203 | 204 | ```properties 205 | [hide-logo] 206 | profile-cond= idle_active and user_data.file_browser.open 207 | profile-restore=copy 208 | osc=no 209 | ``` 210 | 211 | On older versions of mpv you can use the `file_browser-open` field of the `shared-script-properties` property: 212 | 213 | ```properties 214 | [hide-logo] 215 | profile-cond= idle_active and shared_script_properties["file_browser-open"] == "yes" 216 | profile-restore=copy 217 | osc=no 218 | ``` 219 | 220 | See [#55](https://github.com/CogentRedTester/mpv-file-browser/issues/55) for more details on this. 221 | 222 | ## [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input) 223 | 224 | mpv-user-input is a script that provides an API to request text input from the user over the OSD. 225 | It was built using `console.lua` as a base, so supports almost all the same text input commands. 226 | If `user-input.lua` is loaded by mpv, and `user-input-module` is in the `~~/script-modules/` directory, 227 | then using `Alt+o` will open an input box that can be used to directly enter directories for file-browser to open. 228 | 229 | Mpv v0.38 added the `mp.input` module, which means `mpv-user-input` is no-longer necessary from that version onwards. 230 | -------------------------------------------------------------------------------- /addons/README.md: -------------------------------------------------------------------------------- 1 | # addons 2 | 3 | Add-ons are ways to add extra features to file-browser, for example adding support for network file servers like ftp, or implementing virtual directories in the root like recently opened files. 4 | They can be enabled by setting `addon` script-opt to yes, and placing the addon file into the `~~/script-modules/file-browser-addons/` directory. 5 | 6 | Browsing filesystems provided by add-ons should feel identical to the normal handling of the script, 7 | but they may require extra commandline tools be installed. 8 | 9 | Since addons are loaded programatically from the addon directory it is possible for anyone to write their own addon. 10 | Instructions on how to do this are available [here](../docs/addons.md). 11 | 12 | For a list of available addons see the [wiki](https://github.com/CogentRedTester/mpv-file-browser/wiki/Addon-List). 13 | -------------------------------------------------------------------------------- /addons/apache-browser.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which adds support for apache http directory indexes 3 | ]]-- 4 | 5 | local mp = require 'mp' 6 | local msg = require 'mp.msg' 7 | local utils = require 'mp.utils' 8 | local fb = require "file-browser" 9 | 10 | --decodes a URL address 11 | --this piece of code was taken from: https://stackoverflow.com/questions/20405985/lua-decodeuri-luvit/20406960#20406960 12 | ---@type fun(s: string): string 13 | local decodeURI 14 | do 15 | local char, gsub, tonumber = string.char, string.gsub, tonumber 16 | local function _(hex) return char(tonumber(hex, 16)) end 17 | 18 | function decodeURI(s) 19 | s = gsub(s, '%%(%x%x)', _) 20 | return s 21 | end 22 | end 23 | 24 | ---@type ParserConfig 25 | local apache = { 26 | priority = 80, 27 | api_version = "1.1.0" 28 | } 29 | 30 | ---@param name string 31 | ---@return boolean 32 | function apache:can_parse(name) 33 | return name:find("^https?://") ~= nil 34 | end 35 | 36 | ---Send curl errors through the browser empty_text. 37 | ---@param str string 38 | ---@return List 39 | ---@return Opts 40 | local function send_error(str) 41 | return {}, {empty_text = "curl error: "..str} 42 | end 43 | 44 | ---@async 45 | ---@param args string[] 46 | ---@return MPVSubprocessResult 47 | local function execute(args) 48 | msg.trace(utils.to_string(args)) 49 | ---@type boolean, MPVSubprocessResult 50 | local _, cmd = fb.get_parse_state():yield( 51 | mp.command_native_async({ 52 | name = "subprocess", 53 | playback_only = false, 54 | capture_stdout = true, 55 | capture_stderr = true, 56 | args = args 57 | }, fb.coroutine.callback()) 58 | ) 59 | return cmd 60 | end 61 | 62 | ---@async 63 | function apache:parse(directory) 64 | msg.verbose(directory) 65 | 66 | local test = execute({"curl", "-k", "-l", "-I", directory}) 67 | local response = test.stdout:match("(%d%d%d [^\n\r]+)") 68 | if test.stdout:match("Content%-Type: ([^\r\n/]+)") ~= "text" then return nil end 69 | if response ~= "200 OK" then return send_error(response) end 70 | 71 | local html = execute({"curl", "-k", "-l", directory}) 72 | if html.status ~= 0 then return send_error(tostring(html.status)) 73 | elseif not html.stdout:find("%[PARENTDIR%]") then return nil end 74 | 75 | local html_body = html.stdout 76 | local list = {} 77 | for str in string.gmatch(html_body, "[^\r\n]+") do 78 | local valid = true 79 | if str:sub(1,4) ~= "" then valid = false end 80 | 81 | local link = str:match('href="(.-)"') 82 | local alt = str:match('alt="%[(.-)%]"') 83 | 84 | if valid and not alt or not link then valid = false end 85 | if valid and alt == "PARENTDIR" or alt == "ICO" then valid = false end 86 | if valid and link:find("[:?<>|]") then valid = false end 87 | 88 | local is_dir = (alt == "DIR") 89 | if valid and is_dir and not self.valid_dir(link) then valid = false end 90 | if valid and not is_dir and not self.valid_file(link) then valid = false end 91 | 92 | if valid then 93 | msg.trace(alt..": "..link) 94 | table.insert(list, { name = link, type = (is_dir and "dir" or "file"), label = decodeURI(link) }) 95 | end 96 | end 97 | 98 | return list, {filtered = true, directory_label = decodeURI(directory)} 99 | end 100 | 101 | return apache 102 | -------------------------------------------------------------------------------- /addons/favourites.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which adds a Favourites path that can be loaded from the ROOT 3 | ]]-- 4 | 5 | local mp = require "mp" 6 | local msg = require "mp.msg" 7 | 8 | local fb = require 'file-browser' 9 | local save_path = mp.command_native({"expand-path", "~~/script-opts/file_browser_favourites.txt"}) --[[@as string]] 10 | do 11 | local file = io.open(save_path, "a+") 12 | if not file then 13 | msg.error("cannot access file", ("%q"):format(save_path), "make sure that the directory exists") 14 | return {} 15 | end 16 | file:close() 17 | end 18 | 19 | ---@type Item[] 20 | local favourites = {} 21 | local favourites_loaded = false 22 | 23 | ---@type ParserConfig 24 | local favs = { 25 | api_version = "1.8.0", 26 | priority = 30, 27 | cursor = 1 28 | } 29 | 30 | local use_virtual_directory = true 31 | 32 | ---@type table 33 | local full_paths = {} 34 | 35 | ---@param str string 36 | ---@return Item 37 | local function create_favourite_object(str) 38 | local item = { 39 | type = str:sub(-1) == "/" and "dir" or "file", 40 | path = str, 41 | redirect = not use_virtual_directory, 42 | name = str:match("([^/]+/?)$") 43 | } 44 | full_paths[str:match("([^/]+)/?$")] = str 45 | return item 46 | end 47 | 48 | ---@param self Parser 49 | function favs:setup() 50 | self:register_root_item('Favourites/') 51 | end 52 | 53 | local function update_favourites() 54 | local file = io.open(save_path, "r") 55 | if not file then return end 56 | 57 | favourites = {} 58 | for str in file:lines() do 59 | table.insert(favourites, create_favourite_object(str)) 60 | end 61 | file:close() 62 | favourites_loaded = true 63 | end 64 | 65 | function favs:can_parse(directory) 66 | return directory:find("Favourites/") == 1 67 | end 68 | 69 | ---@async 70 | ---@param self Parser 71 | ---@param directory string 72 | ---@return List? 73 | ---@return Opts? 74 | function favs:parse(directory) 75 | if not favourites_loaded then update_favourites() end 76 | if directory == "Favourites/" then 77 | local opts = { 78 | filtered = true, 79 | sorted = true 80 | } 81 | return favourites, opts 82 | end 83 | 84 | if use_virtual_directory then 85 | -- converts the relative favourite path into a full path 86 | local name = directory:match("Favourites/([^/]+)/?") 87 | 88 | local _, finish = directory:find("Favourites/([^/]+/?)") 89 | local full_path = (full_paths[name] or "")..directory:sub(finish+1) 90 | local list, opts = self:defer(full_path or "") 91 | 92 | if not list then return nil end 93 | opts = opts or {} 94 | opts.id = self:get_id() 95 | if opts.directory_label then 96 | opts.directory_label = opts.directory_label:gsub(full_paths[name], "Favourites/"..name..'/') 97 | if opts.directory_label:find("Favourites/") ~= 1 then opts.directory_label = nil end 98 | end 99 | 100 | for _, item in ipairs(list) do 101 | if not item.path then item.redirect = false end 102 | item.path = item.path or full_path..item.name 103 | end 104 | 105 | return list, opts 106 | end 107 | 108 | local path = full_paths[ directory:match("([^/]+/?)$") or "" ] 109 | 110 | local list, opts = self:defer(path) 111 | if not list then return nil end 112 | opts = opts or {} 113 | opts.directory = opts.directory or path 114 | return list, opts 115 | end 116 | 117 | ---@param path string 118 | ---@return integer? 119 | ---@return Item? 120 | local function get_favourite(path) 121 | for index, value in ipairs(favourites) do 122 | if value.path == path then return index, value end 123 | end 124 | end 125 | 126 | --update the browser with new contents of the file 127 | ---@async 128 | local function update_browser() 129 | if favs.get_directory():find("^[fF]avourites/$") then 130 | local cursor = favs.get_selected_index() 131 | fb.rescan_await() 132 | fb.set_selected_index(cursor) 133 | else 134 | fb.clear_cache({'favourites/', 'Favourites/'}) 135 | end 136 | end 137 | 138 | --write the contents of favourites to the file 139 | local function write_to_file() 140 | local file = io.open(save_path, "w+") 141 | if not file then return msg.error(file, "could not open favourites file") end 142 | for _, item in ipairs(favourites) do 143 | file:write(string.format("%s\n", item.path)) 144 | end 145 | file:close() 146 | end 147 | 148 | local function add_favourite(path) 149 | if get_favourite(path) then return end 150 | update_favourites() 151 | table.insert(favourites, create_favourite_object(path)) 152 | write_to_file() 153 | end 154 | 155 | local function remove_favourite(path) 156 | update_favourites() 157 | local index = get_favourite(path) 158 | if not index then return end 159 | table.remove(favourites, index) 160 | write_to_file() 161 | end 162 | 163 | local function move_favourite(path, direction) 164 | update_favourites() 165 | local index, item = get_favourite(path) 166 | if not index or not favourites[index + direction] then return end 167 | 168 | favourites[index] = favourites[index + direction] 169 | favourites[index + direction] = item 170 | write_to_file() 171 | end 172 | 173 | ---@async 174 | local function toggle_favourite(cmd, state, co) 175 | local path = fb.get_full_path(state.list[state.selected], state.directory) 176 | 177 | if state.directory:find("[fF]avourites/$") then remove_favourite(path) 178 | else add_favourite(path) end 179 | update_browser() 180 | end 181 | 182 | ---@async 183 | local function move_key(cmd, state, co) 184 | if not state.directory:find("[fF]avourites/") then return false end 185 | local path = fb.get_full_path(state.list[state.selected], state.directory) 186 | 187 | local cursor = fb.get_selected_index() 188 | if cmd.name == favs:get_id().."/move_up" then 189 | move_favourite(path, -1) 190 | fb.set_selected_index(cursor-1) 191 | else 192 | move_favourite(path, 1) 193 | fb.set_selected_index(cursor+1) 194 | end 195 | update_browser() 196 | end 197 | 198 | update_favourites() 199 | 200 | favs.keybinds = { 201 | { "F", "toggle_favourite", toggle_favourite, {}, }, 202 | { "Ctrl+UP", "move_up", move_key, {repeatable = true} }, 203 | { "Ctrl+DOWN", "move_down", move_key, {repeatable = true} }, 204 | } 205 | 206 | return favs 207 | -------------------------------------------------------------------------------- /addons/ftp-browser.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which adds support for ftp servers 3 | ]]-- 4 | 5 | local mp = require 'mp' 6 | local msg = require 'mp.msg' 7 | local utils = require 'mp.utils' 8 | local fb = require 'file-browser' 9 | 10 | ---@type ParserConfig 11 | local ftp = { 12 | priority = 100, 13 | api_version = "1.1.0" 14 | } 15 | 16 | function ftp:can_parse(directory) 17 | return directory:sub(1, 6) == "ftp://" 18 | end 19 | 20 | ---In my experience curl has been somewhat unreliable when it comes to ftp requests, 21 | ---this fuction retries the request a few times just in case. 22 | ---@async 23 | ---@param args string[] 24 | ---@return MPVSubprocessResult 25 | local function execute(args) 26 | msg.debug(utils.to_string(args)) 27 | ---@type boolean, MPVSubprocessResult 28 | local _, cmd = fb.get_parse_state():yield( 29 | mp.command_native_async({ 30 | name = "subprocess", 31 | playback_only = false, 32 | capture_stdout = true, 33 | capture_stderr = true, 34 | args = args 35 | }, fb.coroutine.callback()) 36 | ) 37 | return cmd 38 | end 39 | 40 | ---Encodes special characters using the URL percent encoding format. 41 | ---@param url string 42 | ---@return string 43 | local function urlEncode(url) 44 | local domain, path = string.match(url, '(ftp://[^/]-/)(.*)') 45 | if not path then return url end 46 | 47 | -- these are the unreserved URI characters according to RFC 3986 48 | -- https://www.rfc-editor.org/rfc/rfc3986#section-2.3 49 | path = string.gsub(path, '[^%w.~_%-]', function(c) 50 | return ('%%%x'):format(string.byte(c)) 51 | end) 52 | return domain..path 53 | end 54 | 55 | ---@async 56 | function ftp:parse(directory) 57 | msg.verbose(directory) 58 | 59 | local res = execute({"curl", "-k", "-g", "--retry", "4", urlEncode(directory)}) 60 | 61 | local entries = execute({"curl", "-k", "-g", "-l", "--retry", "4", urlEncode(directory)}) 62 | 63 | if entries.status == 28 then 64 | msg.error(entries.stderr) 65 | elseif entries.status ~= 0 or res.status ~= 0 then 66 | msg.error(entries.stderr) 67 | return 68 | end 69 | 70 | local response = {} 71 | for str in string.gmatch(res.stdout, "[^\r\n]+") do 72 | table.insert(response, str) 73 | end 74 | 75 | local list = {} 76 | local i = 1 77 | for str in string.gmatch(entries.stdout, "[^\r\n]+") do 78 | if str and response[i] then 79 | msg.trace(str .. ' | ' .. response[i]) 80 | 81 | if response[i]:sub(1,1) == "d" then 82 | table.insert(list, { name = str..'/', type = "dir" }) 83 | else 84 | table.insert(list, { name = str, type = "file" }) 85 | end 86 | 87 | i = i+1 88 | end 89 | end 90 | 91 | return list 92 | end 93 | 94 | return ftp 95 | -------------------------------------------------------------------------------- /addons/m3u-browser.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which adds support for m3u playlists 3 | 4 | If the first entry of a playlist isn't working it is because some playlists are created with random invisible unicode in the first line 5 | Vim makes it easy to detect these 6 | 7 | This addon requires that my API mpv-read-file be available in ~~/script-modules/ 8 | https://github.com/CogentRedTester/mpv-read-file 9 | ]]-- 10 | 11 | ---@type any 12 | local rf = require "read-file" 13 | 14 | ---@type ParserConfig 15 | local m3u = { 16 | priority = 100, 17 | api_version = "1.0.0", 18 | name = "m3u" 19 | } 20 | 21 | ---@type table 22 | local full_paths = {} 23 | 24 | function m3u:setup() 25 | self.register_parseable_extension("m3u") 26 | self.register_parseable_extension("m3u8") 27 | end 28 | 29 | function m3u:can_parse(directory) 30 | return directory:find("m3u8?/?$") ~= nil 31 | end 32 | 33 | function m3u:parse(directory) 34 | directory = string.gsub(directory, "/$", "") 35 | local list = {} 36 | 37 | local path = full_paths[ directory ] or directory 38 | ---@type file* 39 | local playlist = rf.get_file_handler( path ) 40 | 41 | --if we can't read the path then stop here 42 | if not playlist then return {}, {sorted = true, filtered = true, empty_text = "Could not read filepath"} end 43 | 44 | local parent = self.fix_path(path:match("^(.+/[^/]+)/"), true) 45 | 46 | ---@type string 47 | local lines = playlist:read("*a") 48 | 49 | for item in lines:gmatch("[^%c]+") do 50 | item = self.fix_path(item) 51 | local fullpath = self.join_path(parent, item) 52 | 53 | local name = ( self.get_protocol(item) and item or fullpath:match("([^/]+)/?$") ) 54 | table.insert(list, {name = name, path = fullpath, type = "file"}) 55 | end 56 | return list, {filtered = true, sorted = true} 57 | end 58 | 59 | return m3u 60 | -------------------------------------------------------------------------------- /addons/powershell.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which uses powershell commands to parse native directories 3 | 4 | This is slower than the default parser for local drives, but faster for network drives 5 | The drive_letters array below is used to list the drives to use this parser for 6 | ]]-- 7 | 8 | --list the drive letters to use here (case sensitive) 9 | ---@type string[] 10 | local drive_letters = { 11 | "Y", "Z" 12 | } 13 | 14 | local mp = require "mp" 15 | local msg = require "mp.msg" 16 | local fb = require "file-browser" 17 | 18 | ---@type ParserConfig 19 | local wn = { 20 | priority = 109, 21 | api_version = "1.1.0", 22 | name = "powershell", 23 | keybind_name = "file" 24 | } 25 | 26 | ---@type Set 27 | local drives = {} 28 | for _, letter in ipairs(drive_letters) do 29 | drives[letter] = true 30 | end 31 | 32 | ---@async 33 | ---@param args string[] 34 | ---@param parse_state ParseState 35 | ---@return string|nil 36 | ---@return string 37 | local function command(args, parse_state) 38 | ---@type boolean, MPVSubprocessResult 39 | local _, cmd = parse_state:yield( 40 | mp.command_native_async({ 41 | name = "subprocess", 42 | playback_only = false, 43 | capture_stdout = true, 44 | capture_stderr = true, 45 | args = args 46 | }, fb.coroutine.callback()) 47 | ) 48 | 49 | return cmd.status == 0 and cmd.stdout or nil, cmd.stderr 50 | end 51 | 52 | function wn:can_parse(directory) 53 | return directory ~= '' and not self.get_protocol(directory) and drives[ directory:sub(1,1) ] 54 | end 55 | 56 | ---@async 57 | function wn:parse(directory, parse_state) 58 | local list = {} 59 | local files, err = command({"powershell", "-noprofile", "-command", [[ 60 | $dirs = Get-ChildItem -LiteralPath ]]..string.format("%q", directory)..[[ -Directory 61 | $files = Get-ChildItem -LiteralPath ]]..string.format("%q", directory)..[[ -File 62 | 63 | foreach ($n in $dirs.Name) { 64 | $n += "/" 65 | $u8clip = [System.Text.Encoding]::UTF8.GetBytes($n) 66 | [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) 67 | Write-Host "" 68 | } 69 | 70 | foreach ($n in $files.Name) { 71 | $u8clip = [System.Text.Encoding]::UTF8.GetBytes($n) 72 | [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) 73 | Write-Host "" 74 | } 75 | ]]}, parse_state) 76 | 77 | if not files then msg.debug(err) ; return nil end 78 | 79 | for str in files:gmatch("[^\n\r]+") do 80 | local is_dir = str:sub(-1) == "/" 81 | if is_dir and self.valid_dir(str) then 82 | table.insert(list, {name = str, type = "dir"}) 83 | elseif self.valid_file(str) then 84 | table.insert(list, {name = str, type = "file"}) 85 | end 86 | end 87 | 88 | return self.sort(list), {filtered = true, sorted = true} 89 | end 90 | 91 | return wn 92 | -------------------------------------------------------------------------------- /addons/root.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon that loads root items from a `~~/script-opts/file-browser-root.json` file. 3 | The contents of this file will override the root script-opt. 4 | 5 | The json file takes the form of a list array as defined by the addon API: 6 | https://github.com/CogentRedTester/mpv-file-browser/blob/master/addons/addons.md#the-list-array 7 | 8 | The main purpose of this addon is to allow for users to customise the appearance of their root items 9 | using the label or ass fields: 10 | 11 | [ 12 | { "name": "Favourites/" }, 13 | { "label": "~/", "name": "C:/Users/User/" }, 14 | { "label": "1TB HDD", "name": "D:/" }, 15 | { "ass": "{\\c&H007700&}Green Text", "name": "E:/" }, 16 | { "label": "FTP Server", name: "ftp://user:password@server.com/" } 17 | ] 18 | 19 | Make sure local directories always end with `/`. 20 | `path` and `name` behave the same in the root but either name or label should have a value. 21 | ASS styling codes: https://aegi.vmoe.info/docs/3.0/ASS_Tags/ 22 | ]] 23 | 24 | local mp = require 'mp' 25 | local msg = require 'mp.msg' 26 | local utils = require 'mp.utils' 27 | local fb = require 'file-browser' 28 | 29 | -- loads the root json file 30 | local config_path = mp.command_native({'expand-path', '~~/script-opts/file-browser-root.json'}) --[[@as string]] 31 | 32 | local file = io.open(config_path, 'r') 33 | if not file then 34 | msg.error('failed to read file', config_path) 35 | return 36 | end 37 | 38 | ---@class RootConfigItem: Item 39 | ---@field priority number? 40 | 41 | local root_config = utils.parse_json(file:read("*a")) --[=[@as RootConfigItem[]]=] 42 | if not root_config then 43 | msg.error('failed to parse contents of', config_path, '- Check the syntax is correct.') 44 | return 45 | end 46 | 47 | local function setup() 48 | for i, item in ipairs(root_config) do 49 | local priority = item.priority 50 | item.priority = nil 51 | fb.register_root_item(item, priority) 52 | end 53 | end 54 | 55 | ---@type ParserConfig 56 | return { 57 | api_version = '1.4.0', 58 | setup = setup, 59 | priority = -1000, 60 | } 61 | -------------------------------------------------------------------------------- /addons/url-decode.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for file-browser which decodes URLs so that they are more readable 3 | ]] 4 | 5 | ---@type ParserConfig 6 | local urldecode = { 7 | priority = 5, 8 | api_version = "1.0.0" 9 | } 10 | 11 | --decodes a URL address 12 | --this piece of code was taken from: https://stackoverflow.com/questions/20405985/lua-decodeuri-luvit/20406960#20406960 13 | ---@type fun(s: string): string 14 | local decodeURI 15 | do 16 | local char, gsub, tonumber = string.char, string.gsub, tonumber 17 | local function _(hex) return char(tonumber(hex, 16)) end 18 | 19 | function decodeURI(s) 20 | s = gsub(s, '%%(%x%x)', _) 21 | return s 22 | end 23 | end 24 | 25 | function urldecode:can_parse(directory) 26 | return self.get_protocol(directory) ~= nil 27 | end 28 | 29 | ---@async 30 | function urldecode:parse(directory) 31 | local list, opts = self:defer(directory) 32 | opts = opts or {} 33 | if opts.directory and not self.get_protocol(opts.directory) then return list, opts end 34 | 35 | opts.directory_label = decodeURI(opts.directory_label or (opts.directory or directory)) 36 | return list, opts 37 | end 38 | 39 | return urldecode 40 | -------------------------------------------------------------------------------- /docs/custom-keybinds.md: -------------------------------------------------------------------------------- 1 | # Custom Keybinds 2 | 3 | File-browser also supports custom keybinds. These keybinds send normal input commands, but the script will substitute characters in the command strings for specific values depending on the currently open directory, and currently selected item. 4 | This allows for a wide range of customised behaviour, such as loading additional audio tracks from the browser, or copying the path of the selected item to the clipboard. 5 | 6 | The feature is disabled by default, but is enabled with the `custom_keybinds` script-opt. 7 | Keybinds are declared in the `~~/script-opts/file-browser-keybinds.json` file, the config takes the form of an array of json objects, with the following keys: 8 | 9 | | option | required | default | description | 10 | |---------------|----------|------------|--------------------------------------------------------------------------------------------| 11 | | key | yes | - | the key to bind the command to - same syntax as input.conf | 12 | | command | yes | - | json array of commands and arguments | 13 | | name | no | numeric id | name of the script-binding - see [modifying default keybinds](#modifying-default-keybinds) | 14 | | condition | no | - | a Lua [expression](#expressions) - the keybind will only run if this evaluates to true | 15 | | flags | no | - | flags to send to the mpv add_keybind function - see [here](https://mpv.io/manual/master/#lua-scripting-[,flags]]\)) | 16 | | filter | no | - | run the command on just a file (`file`) or folder (`dir`) | 17 | | parser | no | - | run the command only in directories provided by the specified parser. | 18 | | multiselect | no | `false` | command is run on all selected items | 19 | | multi-type | no | `repeat` | which multiselect mode to use - `repeat` or `concat` | 20 | | delay | no | `0` | time to wait between sending repeated multi commands | 21 | | concat-string | no | `' '` (space) | string to insert between items when concatenating multi commands | 22 | | passthrough | no | - | force or ban passthrough behaviour - see [passthrough](#passthrough-keybinds) | 23 | | api_version | no | - | tie the keybind to a particular [addon API version](./addons.md#api-version), printing warnings and throwing errors if the keybind is used with wrong versions | 24 | 25 | Example: 26 | 27 | ```json 28 | { 29 | "key": "KP1", 30 | "command": ["print-text", "example"], 31 | } 32 | ``` 33 | 34 | The command can also be an array of arrays, in order to send multiple commands at once: 35 | 36 | ```json 37 | { 38 | "key": "KP2", 39 | "command": [ 40 | ["print-text", "example2"], 41 | ["show-text", "example2"] 42 | ] 43 | } 44 | ``` 45 | 46 | Filter should not be included unless one wants to limit what types of list entries the command should be run on. 47 | To only run the command for directories use `dir`, to only run the command for files use `file`. 48 | 49 | The parser filter is for filtering keybinds to only work inside directories loaded by specific parsers. 50 | There are two parsers in the base script, the default parser for native filesystems is called `file`, while the root parser is called `root`. 51 | Other parsers can be supplied by addons, and use the addon's filename with `-browser.lua` or just `.lua` stripped unless otherwise stated. 52 | For example `ftp-browser.lua` would have a parser called `ftp`. 53 | You can set the filter to match multiple parsers by separating the names with spaces. 54 | 55 | ```json 56 | { 57 | "key": "KP2", 58 | "command": [ ["print-text", "example3"] ], 59 | "parser": "ftp file" 60 | } 61 | ``` 62 | 63 | The `flags` field is mostly only useful for addons, but can also be useful if one wants a key to be repeatable. 64 | In this case the the keybind would look like the following: 65 | 66 | ```json 67 | { 68 | "key": "p", 69 | "command": ["print-text", "spam-text"], 70 | "flags": { "repeatable": true } 71 | } 72 | ``` 73 | 74 | ## Codes 75 | 76 | The script will scan every string in the command for the special substitution strings, they are: 77 | 78 | | code | description | 79 | |--------|---------------------------------------------------------------------| 80 | | `%%` | escape code for `%` | 81 | | `%f` | filepath of the selected item | 82 | | `%n` | filename of the selected item | 83 | | `%p` | currently open directory | 84 | | `%q` | currently open directory but preferring the directory label | 85 | | `%d` | name of the current directory (characters between the last two '/') | 86 | | `%r` | name of the parser for the currently open directory | 87 | | `%x` | number of items in the currently open directory | 88 | | `%i` | the 1-based index of the selected item in the list | 89 | | `%j` | the 1-based index of the item in a multiselection - returns 1 for single selections | 90 | 91 | Additionally, using the uppercase forms of those codes will send the substituted string through the `string.format("%q", str)` function. 92 | This adds double quotes around the string and automatically escapes any characters which would break the string encapsulation. 93 | This is not necessary for most mpv commands, but can be very useful when sending commands to the OS with the `run` command, 94 | or when passing values into [expressions](#conditional-command-condition-command). 95 | 96 | Example of a command to add an audio track: 97 | 98 | ```json 99 | { 100 | "key": "Ctrl+a", 101 | "command": ["audio-add", "%f"], 102 | "filter": "file" 103 | } 104 | ``` 105 | 106 | Any commands that contain codes representing specific items (`%f`, `%n`, `%i` etc) will 107 | not be run if no item is selected (for example in an empty directory). 108 | In these cases [passthrough](#passthrough-keybinds) rules will apply. 109 | 110 | ## Multiselect Commands 111 | 112 | When multiple items are selected the command can be run for all items in the order they appear on the screen. 113 | This can be controlled by the `multiselect` flag, which takes a boolean value. 114 | When not set the flag defaults to `false`. 115 | 116 | There are two different multiselect modes, controlled by the `multi-type` option. There are two options: 117 | 118 | ### `repeat` 119 | 120 | The default mode that sends the commands once for each item that is selected. 121 | If time is needed between running commands of multiple selected items (for example, due to file handlers) then the `delay` option can be used to set a duration (in seconds) between commands. 122 | 123 | ### `concat` 124 | 125 | Run a single command, but replace item specific codes with a concatenated string made from each selected item. 126 | For example `["print-text", "%n" ]` would print the name of each item selected separated by `' '` (space). 127 | The string inserted between each item is determined by the `concat-string` option, but `' '` is the default. 128 | 129 | ## Passthrough Keybinds 130 | 131 | When loading keybinds from the json file file-browser will move down the list and overwrite any existing bindings with the same key. 132 | This means the lower an item on the list, the higher preference it has. 133 | However, file-browser implements a layered passthrough system for its keybinds; if a keybind is blocked from running by user filters, then the next highest preference command will be sent, continuing until a command is sent or there are no more keybinds. 134 | The default dynamic keybinds are considered the lowest priority. 135 | 136 | The `filter`, `parser`, and `condition` options can all trigger passthrough, as well as some [codes](#codes). 137 | If a multi-select command is run on multiple items then passthrough will occur if any of the selected items fail the filters. 138 | 139 | Passthrough can be forcibly disabled or enabled using the passthrough option. 140 | When set to `true` passthrough will always be activate regardless of the state of the filters. 141 | 142 | ## Modifying Default Keybinds 143 | 144 | Since the custom keybinds are applied after the default dynamic keybinds they can be used to overwrite the default bindings. 145 | Setting new keys for the existing binds can be done with the `script-binding [binding-name]` command, where `binding-name` is the full name of the keybinding. 146 | For this script the names of the dynamic keybinds are in the format `file_browser/dynamic/[name]` where `name` is a unique identifier documented in the [keybinds](README.md#keybinds) table. 147 | 148 | For example to change the scroll buttons from the arrows to the scroll wheel: 149 | 150 | ```json 151 | [ 152 | { 153 | "key": "WHEEL_UP", 154 | "command": ["script-binding", "file_browser/dynamic/scroll_up"] 155 | }, 156 | { 157 | "key": "WHEEL_DOWN", 158 | "command": ["script-binding", "file_browser/dynamic/scroll_down"] 159 | }, 160 | { 161 | "key": "UP", 162 | "command": ["osd-auto", "add", "volume", "2"] 163 | }, 164 | { 165 | "key": "DOWN", 166 | "command": ["osd-auto", "add", "volume", "-2"] 167 | } 168 | ] 169 | ``` 170 | 171 | Custom keybinds can be called using the same method, but users must set the `name` value inside the `file-browser-keybinds.json` file. 172 | To avoid conflicts custom keybinds use the format: `file_browser/dynamic/custom/[name]`. 173 | 174 | ## Expressions 175 | 176 | Expressions are used to evaluate Lua code into a string that can be used for commands. 177 | These behave similarly to those used for [`profile-cond`](https://mpv.io/manual/master/#conditional-auto-profiles) 178 | values. In an expression the `mp`, `mp.msg`, and `mp.utils` modules are available as `mp`, `msg`, and `utils` respectively. 179 | Additionally, in mpv v0.38+ the `mp.input` module is available as `input`. 180 | 181 | The file-browser [addon API](addons/addons.md#the-api) is available as `fb` and if [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input) 182 | is installed then user-input API will be available in `user_input`. 183 | 184 | This example only runs the keybind if the browser is in the Windows C drive or if 185 | the selected item is a matroska file: 186 | 187 | ```json 188 | [ 189 | { 190 | "key": "KP1", 191 | "command": ["print-text", "in my C:/ drive!"], 192 | "condition": "(%P):find('C:/') == 1" 193 | }, 194 | { 195 | "key": "KP2", 196 | "command": ["print-text", "Matroska File!"], 197 | "condition": "fb.get_extension(%N) == 'mkv'" 198 | } 199 | ] 200 | ``` 201 | 202 | If the `condition` expression contains any item specific codes (`%F`, `%I`, etc) then it will be 203 | evaluated on each individual item, otherwise it will evaluated once for the whole keybind. 204 | If a code is invalid (for example using `%i` in empty directories) then the expression returns false. 205 | 206 | There are some utility script messages that extend the power of expressions. 207 | [`conditional-command`](#conditional-command-condition-command) allows one to specify conditions that 208 | can apply to individual items or commands. The tradeoff is that you lose the automated passthrough behaviour. 209 | There is also [`evaluate-expressions`](#evaluate-expressions-command) which allows one to evaluate expressions inside commands. 210 | 211 | ## Utility Script Messages 212 | 213 | There are a small number of custom script messages defined by file-browser to support custom keybinds. 214 | 215 | ### `=> ` 216 | 217 | A basic script message that makes it easier to chain multiple utility script messages together. 218 | Any `=>` string will be substituted for `script-message`. 219 | 220 | ```json 221 | { 222 | "key": "KP1", 223 | "command": ["script-message", "=>", "delay-command", "%j * 2", "=>", "evaluate-expressions", "print-text", "!{%j * 2}"], 224 | "multiselect": true 225 | } 226 | ``` 227 | 228 | ### `conditional-command [condition] ` 229 | 230 | Runs the following command only if the condition [expression](#expressions) is `true`. 231 | 232 | This example command will only run if the player is currently paused: 233 | 234 | ```json 235 | { 236 | "key": "KP1", 237 | "command": ["script-message", "conditional-command", "mp.get_property_bool('pause')", "print-text", "is paused"], 238 | } 239 | ``` 240 | 241 | Custom keybind codes are evaluated before the expressions. 242 | 243 | This example only runs if the currently selected item in the browser has a `.mkv` extension: 244 | 245 | ```json 246 | { 247 | "key": "KP1", 248 | "command": ["script-message", "conditional-command", "fb.get_extension(%N) == 'mkv'", "print-text", "a matroska file"], 249 | } 250 | ``` 251 | 252 | ### `delay-command [delay] ` 253 | 254 | Delays the following command by `[delay]` seconds. 255 | Delay is an [expression](#expressions). 256 | 257 | The following example will send the `print-text` command after 5 seconds: 258 | 259 | ```json 260 | { 261 | "key": "KP1", 262 | "command": ["script-message", "delay-command", "5", "print-text", "example"], 263 | } 264 | ``` 265 | 266 | ### `evaluate-expressions ` 267 | 268 | Evaluates embedded Lua expressions in the following command. 269 | Expressions have the same behaviour as the [`conditional-command`](#conditional-command-condition-command) script-message. 270 | Expressions must be surrounded by `!{}` characters. 271 | Additional `!` characters can be placed at the start of the expression to 272 | escape the evaluation. 273 | 274 | For example the following keybind will print 3 to the console: 275 | 276 | ```json 277 | { 278 | "key": "KP1", 279 | "command": ["script-message", "evaluate-expressions", "print-text", "!{1 + 2}"], 280 | } 281 | ``` 282 | 283 | This example replaces all `/` characters in the path with `\` 284 | (note that the `\` needs to be escaped twice, once for the json file, and once for the string in the lua expression): 285 | 286 | ```json 287 | { 288 | "key": "KP1", 289 | "command": ["script-message", "evaluate-expressions", "print-text", "!{ string.gsub(%F, '/', '\\\\') }"], 290 | } 291 | ``` 292 | 293 | ### `run-statement ` 294 | 295 | Runs the following string a as a Lua statement. This is similar to an [expression](#expressions), 296 | but instead of the code evaluating to a value it must run a series of statements. Basically it allows 297 | for function bodies to be embedded into custom keybinds. All the same modules are available. 298 | If multiple strings are sent to the script-message then they will be concatenated together with newlines. 299 | 300 | The following keybind will use [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input) to 301 | rename items in file-browser: 302 | 303 | ```json 304 | { 305 | "key": "KP1", 306 | "command": ["script-message", "run-statement", 307 | "assert(user_input, 'install mpv-user-input!')", 308 | 309 | "local line, err = user_input.get_user_input_co({", 310 | "id = 'rename-file',", 311 | "source = 'custom-keybind',", 312 | "request_text = 'rename file:',", 313 | "queueable = true,", 314 | "default_input = %N,", 315 | "cursor_pos = #(%N) - #fb.get_extension(%N, '')", 316 | "})", 317 | 318 | "if not line then return end", 319 | "os.rename(%F, utils.join_path(%P, line))", 320 | 321 | "fb.rescan()" 322 | ], 323 | "parser": "file", 324 | "multiselect": true 325 | } 326 | ``` 327 | 328 | ## Examples 329 | 330 | See [here](file-browser-keybinds.json). 331 | -------------------------------------------------------------------------------- /docs/file-browser-keybinds.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "comment": "deletes the currently selected file", 4 | "key": "Alt+DEL", 5 | "command": ["script-message", "run-statement", "os.remove(%F) ; fb.rescan()"], 6 | "multiselect": true, 7 | "multi-type": "repeat" 8 | }, 9 | { 10 | "comment": "opens the currently selected items in a new mpv window", 11 | "key": "Ctrl+ENTER", 12 | "command": ["run", "mpv", "%F"], 13 | "multiselect": true, 14 | "multi-type": "concat" 15 | }, 16 | { 17 | "key": "Ctrl+c", 18 | "command": [ 19 | ["run", "powershell", "-command", "Set-Clipboard", "%F"], 20 | ["print-text", "copied filepath to clipboard"] 21 | ], 22 | "condition": "fb.get_platform() == 'windows'", 23 | "api_version": "1.9.0", 24 | "multiselect": true, 25 | "delay": 0.3 26 | }, 27 | 28 | 29 | { 30 | "key": "WHEEL_UP", 31 | "command": ["script-binding", "file_browser/dynamic/scroll_up"], 32 | "flags": { "repeat": true } 33 | }, 34 | { 35 | "key": "WHEEL_DOWN", 36 | "command": ["script-binding", "file_browser/dynamic/scroll_down"], 37 | "flags": { "repeat": true } 38 | }, 39 | { 40 | "key": "MBTN_LEFT", 41 | "command": ["script-binding", "file_browser/dynamic/down_dir"] 42 | }, 43 | { 44 | "key": "MBTN_RIGHT", 45 | "command": ["script-binding", "file_browser/dynamic/up_dir"] 46 | }, 47 | { 48 | "key": "MBTN_MID", 49 | "command": ["script-binding", "file_browser/dynamic/play"] 50 | } 51 | ] -------------------------------------------------------------------------------- /docs/file_browser.conf: -------------------------------------------------------------------------------- 1 | ####################################################### 2 | # This is the default config file for mpv-file-browser 3 | # https://github.com/CogentRedTester/mpv-file-browser 4 | ####################################################### 5 | 6 | #################################### 7 | ######## browser settings ########## 8 | #################################### 9 | 10 | # Root directories, separated by commas. 11 | # `C:/` and `/` are automatically added on Windows and non-windows systems, respectively. 12 | # The order of automatically added items can be changed by entering them here manually. 13 | root=~/ 14 | 15 | # characters to separate root directories, each character works individually 16 | root_separators=, 17 | 18 | # number of entries to show on the screen at once 19 | num_entries=20 20 | 21 | # number of directories to keep in the history. 22 | # A size of 0 disables the history. 23 | history_size=100 24 | 25 | # wrap the cursor around the top and bottom of the list 26 | wrap=no 27 | 28 | # enables loading external addons 29 | addons=yes 30 | 31 | # enable custom keybinds 32 | # the keybind json file must go in ~~/script-opts 33 | custom_keybinds=yes 34 | 35 | # Automatically detect windows drives and adds them to the root. 36 | # Using Ctrl+r in the root will run another scan. 37 | auto_detect_windows_drives=yes 38 | 39 | # when opening the browser in idle mode prefer the current working directory over the root 40 | # note that the working directory is set as the 'current' directory regardless, so `home` will 41 | # move the browser there even if this option is set to false 42 | default_to_working_directory=no 43 | 44 | # When opening the browser prefer the directory last opened by a previous mpv instance of file-browser. 45 | # Overrides the `default_to_working_directory` option. 46 | # Requires `save_last_opened_directory` to be `yes`. 47 | # Uses the internal `last-opened-directory` addon. 48 | default_to_last_opened_directory=no 49 | 50 | # Whether to save the last opened directory. 51 | save_last_opened_directory=no 52 | 53 | # Move the cursor to the currently playing item (if available) when the playing file changes. 54 | cursor_follows_playing_item=no 55 | 56 | #################################### 57 | ########## filter settings ######### 58 | #################################### 59 | 60 | # only show files compatible with mpv in the browser 61 | filter_files=yes 62 | 63 | # file-browser only shows files that are compatible with mpv by default 64 | # adding a file extension to this list will add it to the extension whitelist 65 | # extensions are separated with commas, do not use any spaces 66 | extension_whitelist= 67 | 68 | # add file extensions to this list to disable default filetypes 69 | # note that this will also override audio/subtitle_extension options below 70 | extension_blacklist= 71 | 72 | # files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist 73 | # items on this list are automatically added to the extension whitelist 74 | audio_extensions=mka,dts,dtshd,dts-hd,truehd,true-hd 75 | 76 | # files with these extensions will be added as additional subtitle tracks for the current file instead of appended to the playlist 77 | # items on this list are automatically added to the extension whitelist 78 | subtitle_extensions=etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs 79 | 80 | # filter directories or files starting with a period like .config for linux systems 81 | # auto will show dot entries on windows and hide them otherwise 82 | filter_dot_dirs=auto 83 | filter_dot_files=auto 84 | 85 | #################################### 86 | ###### file loading settings ####### 87 | #################################### 88 | 89 | # this option reverses the behaviour of the alt+ENTER keybind 90 | # when disabled the keybind is required to enable autoload for the file 91 | # when enabled the keybind disables autoload for the file 92 | autoload=no 93 | 94 | # experimental feature that recurses directories concurrently when appending items to the playlist 95 | # this feature has the potential for massive performance improvements when using addons with asynchronous IO 96 | concurrent_recursion=yes 97 | 98 | # maximum number of recursions that can run concurrently 99 | # if this number is too high it risks overflowing the mpv event queue, which will cause some directories to be dropped entirely 100 | max_concurrency=16 101 | 102 | # substitute forward slashes for backslashes when appending a local file to the playlist 103 | # may be useful on windows systems 104 | substitute_backslash=no 105 | 106 | # if autoload is triggered by selecting the currently playing file, then 107 | # the current file will have it's watch-later config saved before being closed and re-opened 108 | # essentially the current file will not be restarted 109 | autoload_save_current=yes 110 | 111 | #################################### 112 | ### directory parsing settings ##### 113 | #################################### 114 | 115 | # a directory cache to improve directory reading time, 116 | # enable if it takes a long time to load directories. 117 | # May cause 'ghost' files to be shown that no-longer exist or 118 | # fail to show files that have recently been created. 119 | # Reloading the directory with Ctrl+r will never use the cache. 120 | # Use Ctrl+Shift+r to forcibly clear the cache. 121 | cache=no 122 | 123 | # Enables the internal `ls` addon that parses directories using the `ls` commandline tool. 124 | # Allows directory parsing to run concurrently, which prevents the browser from locking up. 125 | # Automatically disables itself on Windows systems. 126 | ls_parser=yes 127 | 128 | # Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe. 129 | # Allows directory parsing to run concurrently, which prevents the browser from locking up. 130 | # Automatically disables itself on non-Windows systems. 131 | windir_parser=yes 132 | 133 | # when moving up a directory do not stop on empty protocol schemes like `ftp://` 134 | # e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://` 135 | skip_protocol_schemes=yes 136 | 137 | # map optical device paths to their respective file paths, 138 | # e.g. mapping bd:// to the value of the bluray-device property 139 | map_bd_device=yes 140 | map_dvd_device=yes 141 | map_cdda_device=yes 142 | 143 | #################################### 144 | ########## misc settings ########### 145 | #################################### 146 | 147 | # turn the OSC idle screen off and on when opening and closing the browser 148 | # this should only be enabled if file-browser is the only thing controlling the idle-screen, 149 | # if multiple sources attempt to control the idle-screen at the same time it can cause unexpected behaviour. 150 | toggle_idlescreen=no 151 | 152 | # interpret backslashes `\` in paths as forward slashes `/` 153 | # this is useful on Windows, which natively uses backslashes. 154 | # As backslashes are valid filename characters in Unix systems this could 155 | # cause mangled paths, though such filenames are rare. 156 | # Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform` 157 | # property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`. 158 | normalise_backslash=auto 159 | 160 | # Set the current open status of the browser in the `file_browser/open` field of the `user-data` property. 161 | # This property is only available in mpv v0.36+. 162 | set_user_data=yes 163 | 164 | # Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property. 165 | # This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically disable this option. 166 | set_shared_script_properties=no 167 | 168 | #################################### 169 | ########## file overrides ######### 170 | #################################### 171 | 172 | # directory to load external modules - currently just user-input-module 173 | module_directory=~~/script-modules 174 | addon_directory=~~/script-modules/file-browser-addons 175 | custom_keybinds_file=~~/script-opts/file-browser-keybinds.json 176 | last_opened_directory_file=~~state/file_browser-last_opened_directory 177 | 178 | #################################### 179 | ######### style settings ########### 180 | #################################### 181 | 182 | # Replace the user's home directory with `~/` in the header. 183 | # Uses the internal home-label addon. 184 | home_label=yes 185 | 186 | # force file-browser to use a specific alignment (default: top-left) 187 | # set to auto to use the default mpv osd-align options 188 | # Options: 'auto'|'top'|'center'|'bottom' 189 | align_y=top 190 | # Options: 'auto'|'left'|'center'|'right' 191 | align_x=left 192 | 193 | # The format string used for the header. Uses custom-keybind substitution codes to 194 | # dynamically change the contents of the header (see: docs/custom-keybinds.md#codes) 195 | # and supports the additional code `%^`which re-applies the default header ass style. 196 | # The original style used before the current one was: %q\N---------------------------------------------------- 197 | format_string_header={\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------ 198 | 199 | # The format strings used for the wrappers. Supports custom-keybind substitution codes, and 200 | # supports two additional codes: `%<` and `%>` to show the number of items before and after the visible list, respectively. 201 | # Setting these options to empty strings will disable the wrappers. 202 | # Original styles used before the current ones were: 203 | # top: %< item(s) above\N 204 | # bottom: \N%> item(s) remaining 205 | format_string_topwrapper=... 206 | format_string_bottomwrapper=... 207 | 208 | # allows custom icons be set for the folder and cursor 209 | # the `\h` character is a hard space to add padding 210 | folder_icon={\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h 211 | cursor_icon={\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h 212 | cursor_icon_flipped={\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 6.86{\p0}\h 213 | 214 | # set the opacity of fonts in hexadecimal from 00 (opaque) to FF (transparent) 215 | font_opacity_selection_marker=99 216 | 217 | # print the header in bold font 218 | font_bold_header=yes 219 | 220 | # scale the size of the browser; 2 would double the size, 0.5 would halve it, etc. 221 | # the header and wrapper scaling is relative to the base scaling 222 | scaling_factor_base=1 223 | scaling_factor_header=1.4 224 | scaling_factor_wrappers=1 225 | 226 | # set custom font names, blank is the default 227 | # setting custom fonts for the folder/cursor can fix broken or missing icons 228 | font_name_header= 229 | font_name_body= 230 | font_name_wrappers= 231 | font_name_folder= 232 | font_name_cursor= 233 | 234 | # set custom font colours 235 | # colours are in hexadecimal format in Blue Green Red order 236 | # note that this is the opposite order to RGB colour codes 237 | font_colour_header=00ccff 238 | font_colour_body=ffffff 239 | font_colour_wrappers=00ccff 240 | font_colour_cursor=00ccff 241 | font_colour_escape_chars=413eff 242 | 243 | # these are colours applied to list items in different states 244 | font_colour_selected=fce788 245 | font_colour_multiselect=fcad88 246 | font_colour_playing=33ff66 247 | font_colour_playing_multiselected=22b547 248 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | mpv-file-browser 3 | 4 | This script allows users to browse and open files and folders entirely from within mpv. 5 | The script uses nothing outside the mpv API, so should work identically on all platforms. 6 | The browser can move up and down directories, start playing files and folders, or add them to the queue. 7 | 8 | For full documentation see: https://github.com/CogentRedTester/mpv-file-browser 9 | ]]-- 10 | 11 | local mp = require 'mp' 12 | 13 | local o = require 'modules.options' 14 | 15 | -- setting the package paths 16 | package.path = mp.command_native({"expand-path", o.module_directory}).."/?.lua;"..package.path 17 | 18 | local addons = require 'modules.addons' 19 | local keybinds = require 'modules.keybinds' 20 | local setup = require 'modules.setup' 21 | local controls = require 'modules.controls' 22 | local observers = require 'modules.observers' 23 | local script_messages = require 'modules.script-messages' 24 | 25 | local input_loaded, input = pcall(require, "mp.input") 26 | local user_input_loaded, user_input = pcall(require, "user-input-module") 27 | 28 | 29 | -- root and addon setup 30 | setup.root() 31 | addons.load_internal_addons() 32 | if o.addons then addons.load_external_addons() end 33 | addons.setup_addons() 34 | 35 | --these need to be below the addon setup in case any parsers add custom entries 36 | setup.extensions_list() 37 | keybinds.setup_keybinds() 38 | 39 | -- property observers 40 | mp.observe_property('path', 'string', observers.current_directory) 41 | if o.map_dvd_device then mp.observe_property('dvd-device', 'string', observers.dvd_device) end 42 | if o.map_bd_device then mp.observe_property('bluray-device', 'string', observers.bd_device) end 43 | if o.map_cdda_device then mp.observe_property('cdda-device', 'string', observers.cd_device) end 44 | if o.align_x == 'auto' then mp.observe_property('osd-align-x', 'string', observers.osd_align) end 45 | if o.align_y == 'auto' then mp.observe_property('osd-align-y', 'string', observers.osd_align) end 46 | 47 | -- scripts messages 48 | mp.register_script_message('=>', script_messages.chain) 49 | mp.register_script_message('delay-command', script_messages.delay_command) 50 | mp.register_script_message('conditional-command', script_messages.conditional_command) 51 | mp.register_script_message('evaluate-expressions', script_messages.evaluate_expressions) 52 | mp.register_script_message('run-statement', script_messages.run_statement) 53 | 54 | mp.register_script_message('browse-directory', controls.browse_directory) 55 | mp.register_script_message("get-directory-contents", script_messages.get_directory_contents) 56 | 57 | --declares the keybind to open the browser 58 | mp.add_key_binding('MENU','browse-files', controls.toggle) 59 | mp.add_key_binding('Ctrl+o','open-browser', controls.open) 60 | 61 | if input_loaded then 62 | mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function() 63 | input.get({ 64 | prompt = "open directory:", 65 | id = "file-browser/browse-directory", 66 | submit = function(text) 67 | controls.browse_directory(text) 68 | input.terminate() 69 | end 70 | }) 71 | end) 72 | elseif user_input_loaded then 73 | mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function() 74 | user_input.get_user_input(controls.browse_directory, {request_text = "open directory:"}) 75 | end) 76 | end 77 | -------------------------------------------------------------------------------- /modules/addons.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | local utils = require 'mp.utils' 4 | 5 | local o = require 'modules.options' 6 | local g = require 'modules.globals' 7 | local fb_utils = require 'modules.utils' 8 | local parser_API = require 'modules.apis.parser' 9 | 10 | local API_MAJOR, API_MINOR, API_PATCH = g.API_VERSION:match("(%d+)%.(%d+)%.(%d+)") 11 | API_MAJOR, API_MINOR, API_PATCH = tonumber(API_MAJOR), tonumber(API_MINOR), tonumber(API_PATCH) 12 | 13 | ---checks if the given parser has a valid version number 14 | ---@param parser Parser|Keybind 15 | ---@param id string 16 | ---@return boolean? 17 | local function check_api_version(parser, id) 18 | if parser.version then 19 | msg.warn(('%s: use of the `version` field is deprecated - use `api_version` instead'):format(id)) 20 | parser.api_version = parser.version 21 | end 22 | 23 | local version = parser.api_version 24 | if type(version) ~= 'string' then return msg.error(("%s: field `api_version` must be a string, got %s"):format(id, tostring(version))) end 25 | 26 | local major, minor = version:match("(%d+)%.(%d+)") 27 | major, minor = tonumber(major), tonumber(minor) 28 | 29 | if not major or not minor then 30 | return msg.error(("%s: invalid version number, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version)) 31 | elseif major ~= API_MAJOR then 32 | return msg.error(("%s has wrong major version number, expected v%d.x.x, got, v%s"):format(id, API_MAJOR, version)) 33 | elseif minor > API_MINOR then 34 | msg.warn(("%s has newer minor version number than API, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version)) 35 | end 36 | return true 37 | end 38 | 39 | ---create a unique id for the given parser 40 | ---@param parser Parser 41 | local function set_parser_id(parser) 42 | local name = parser.name 43 | if g.parsers[name] then 44 | local n = 2 45 | name = parser.name.."_"..n 46 | while g.parsers[name] do 47 | n = n + 1 48 | name = parser.name.."_"..n 49 | end 50 | end 51 | 52 | g.parsers[name] = parser 53 | g.parsers[parser] = { id = name } 54 | end 55 | 56 | ---runs an addon in a separate environment 57 | ---@param path string 58 | ---@return unknown 59 | local function run_addon(path) 60 | local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$")) 61 | local addon_environment = fb_utils.redirect_table(_G) 62 | addon_environment._G = addon_environment ---@diagnostic disable-line inject-field 63 | 64 | --gives each addon custom debug messages 65 | addon_environment.package = fb_utils.redirect_table(addon_environment.package) ---@diagnostic disable-line inject-field 66 | addon_environment.package.loaded = fb_utils.redirect_table(addon_environment.package.loaded) 67 | local msg_module = { 68 | log = function(level, ...) msg.log(level, name_sqbr, ...) end, 69 | fatal = function(...) return msg.fatal(name_sqbr, ...) end, 70 | error = function(...) return msg.error(name_sqbr, ...) end, 71 | warn = function(...) return msg.warn(name_sqbr, ...) end, 72 | info = function(...) return msg.info(name_sqbr, ...) end, 73 | verbose = function(...) return msg.verbose(name_sqbr, ...) end, 74 | debug = function(...) return msg.debug(name_sqbr, ...) end, 75 | trace = function(...) return msg.trace(name_sqbr, ...) end, 76 | } 77 | addon_environment.print = msg_module.info ---@diagnostic disable-line inject-field 78 | 79 | addon_environment.require = function(module) ---@diagnostic disable-line inject-field 80 | if module == "mp.msg" then return msg_module end 81 | return require(module) 82 | end 83 | 84 | ---@type function?, string? 85 | local chunk, err 86 | if setfenv then ---@diagnostic disable-line deprecated 87 | --since I stupidly named a function loadfile I need to specify the global one 88 | --I've been using the name too long to want to change it now 89 | chunk, err = _G.loadfile(path) 90 | if not chunk then return msg.error(err) end 91 | setfenv(chunk, addon_environment) ---@diagnostic disable-line deprecated 92 | else 93 | chunk, err = _G.loadfile(path, "bt", addon_environment) ---@diagnostic disable-line redundant-parameter 94 | if not chunk then return msg.error(err) end 95 | end 96 | 97 | ---@diagnostic disable-next-line no-unknown 98 | local success, result = xpcall(chunk, fb_utils.traceback) 99 | return success and result or nil 100 | end 101 | 102 | ---Setup an internal or external parser. 103 | ---Note that we're somewhat bypassing the type system here as we're converting from a 104 | ---ParserConfig object to a Parser object. As such we need to make sure that the 105 | ---we're doing everything correctly. A 2.0 release of the addon API could simplify 106 | ---this by formally separating ParserConfigs from Parsers and providing an 107 | ---API to register parsers. 108 | ---@param parser ParserConfig 109 | ---@param file string 110 | ---@return nil 111 | local function setup_parser(parser, file) 112 | parser = setmetatable(parser, { __index = parser_API }) --[[@as Parser]] 113 | parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "") 114 | 115 | set_parser_id(parser) 116 | if not check_api_version(parser, file) then return msg.error("aborting load of parser", parser:get_id(), "from", file) end 117 | 118 | msg.verbose("imported parser", parser:get_id(), "from", file) 119 | 120 | --sets missing functions 121 | if not parser.can_parse then 122 | if parser.parse then parser.can_parse = function() return true end 123 | else parser.can_parse = function() return false end end 124 | end 125 | 126 | if parser.priority == nil then parser.priority = 0 end 127 | if type(parser.priority) ~= "number" then return msg.error("parser", parser:get_id(), "needs a numeric priority") end 128 | 129 | table.insert(g.parsers, parser) 130 | end 131 | 132 | ---load an external addon 133 | ---@param file string 134 | ---@param path string 135 | ---@return nil 136 | local function setup_addon(file, path) 137 | if file:sub(-4) ~= ".lua" then return msg.verbose(path, "is not a lua file - aborting addon setup") end 138 | 139 | local addon_parsers = run_addon(path) --[=[@as ParserConfig|ParserConfig[]]=] 140 | if addon_parsers and not next(addon_parsers) then return msg.verbose('addon', path, 'returned empry table - special case, ignoring') end 141 | if not addon_parsers or type(addon_parsers) ~= "table" then return msg.error("addon", path, "did not return a table") end 142 | 143 | --if the table contains a priority key then we assume it isn't an array of parsers 144 | if not addon_parsers[1] then addon_parsers = {addon_parsers} end 145 | 146 | for _, parser in ipairs(addon_parsers --[=[@as ParserConfig[]]=]) do 147 | setup_parser(parser, file) 148 | end 149 | end 150 | 151 | ---loading external addons 152 | ---@param directory string 153 | ---@return nil 154 | local function load_addons(directory) 155 | directory = fb_utils.fix_path(directory, true) 156 | 157 | local files = utils.readdir(directory) 158 | if not files then return msg.verbose('not loading external addons - could not read', o.addon_directory) end 159 | 160 | for _, file in ipairs(files) do 161 | setup_addon(file, directory..file) 162 | end 163 | end 164 | 165 | local function load_internal_addons() 166 | local script_dir = mp.get_script_directory() 167 | if not script_dir then return msg.error('script is not being run as a directory script!') end 168 | local internal_addon_dir = script_dir..'/modules/addons/' 169 | load_addons(internal_addon_dir) 170 | end 171 | 172 | local function load_external_addons() 173 | local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'}) --[[@as string|nil]] 174 | if not addon_dir then return msg.verbose('not loading external addons - could not resolve', o.addon_directory) end 175 | load_addons(addon_dir) 176 | end 177 | 178 | ---Orders the addons by priority, sets the parser index values, 179 | ---and runs the setup methods of the addons. 180 | local function setup_addons() 181 | table.sort(g.parsers, function(a, b) return a.priority < b.priority end) 182 | 183 | --we want to store the indexes of the parsers 184 | for i = #g.parsers, 1, -1 do g.parsers[ g.parsers[i] ].index = i end 185 | 186 | --we want to run the setup functions for each addon 187 | for index, parser in ipairs(g.parsers) do 188 | if parser.setup then 189 | local success = xpcall(function() parser:setup() end, fb_utils.traceback) 190 | if not success then 191 | msg.error("parser", parser:get_id(), "threw an error in the setup method - removing from list of parsers") 192 | table.remove(g.parsers, index) 193 | end 194 | end 195 | end 196 | end 197 | 198 | ---@class addons 199 | return { 200 | check_api_version = check_api_version, 201 | load_internal_addons = load_internal_addons, 202 | load_external_addons = load_external_addons, 203 | setup_addons = setup_addons, 204 | } 205 | -------------------------------------------------------------------------------- /modules/addons/cache.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file is an internal file-browser addon. 3 | It should not be imported like a normal module. 4 | 5 | Maintains a cache of the accessed directories to improve 6 | parsing speed. Disabled by default. 7 | ]] 8 | 9 | local mp = require 'mp' 10 | local msg = require 'mp.msg' 11 | local utils = require 'mp.utils' 12 | 13 | local fb = require 'file-browser' 14 | 15 | ---@type ParserConfig 16 | local cacheParser = { 17 | name = 'cache', 18 | priority = 0, 19 | api_version = '1.9', 20 | } 21 | 22 | ---@class CacheEntry 23 | ---@field list List 24 | ---@field opts Opts? 25 | ---@field timeout MPTimer 26 | 27 | ---@type table 28 | local cache = {} 29 | 30 | ---@type table 31 | local pending_parses = {} 32 | 33 | ---@param directories? string[] 34 | local function clear_cache(directories) 35 | if directories then 36 | msg.debug('clearing cache for', #directories, 'directorie(s)') 37 | for _, dir in ipairs(directories) do 38 | if cache[dir] then 39 | msg.trace('clearing cache for', dir) 40 | cache[dir].timeout:kill() 41 | cache[dir] = nil 42 | end 43 | end 44 | else 45 | msg.debug('clearing cache') 46 | for _, entry in pairs(cache) do 47 | entry.timeout:kill() 48 | end 49 | cache = {} 50 | end 51 | end 52 | 53 | ---@type string 54 | local prev_directory = '' 55 | 56 | function cacheParser:can_parse(directory, parse_state) 57 | -- allows the cache to be forcibly used or bypassed with the 58 | -- cache/use parse property. 59 | if parse_state.properties.cache and parse_state.properties.cache.use ~= nil then 60 | if parse_state.source == 'browser' then prev_directory = directory end 61 | return parse_state.properties.cache.use 62 | end 63 | 64 | -- the script message is guaranteed to always bypass the cache 65 | if parse_state.source == 'script-message' then return false end 66 | if not fb.get_opt('cache') or directory == '' then return false end 67 | 68 | -- clear the cache if reloading the current directory in the browser 69 | -- this means that fb.rescan() should maintain expected behaviour 70 | if parse_state.source == 'browser' then 71 | if prev_directory == directory then clear_cache({directory}) end 72 | prev_directory = directory 73 | end 74 | 75 | return true 76 | end 77 | 78 | ---@async 79 | function cacheParser:parse(directory) 80 | if cache[directory] then 81 | msg.verbose('fetching', directory, 'contents from cache') 82 | cache[directory].timeout:kill() 83 | cache[directory].timeout:resume() 84 | return cache[directory].list, cache[directory].opts 85 | end 86 | 87 | ---@type List?, Opts? 88 | local list, opts 89 | 90 | -- if another parse is already running on the same directory, then wait and use the same result 91 | if not pending_parses[directory] then 92 | pending_parses[directory] = {} 93 | list, opts = self:defer(directory) 94 | else 95 | msg.debug('parse for', directory, 'already running - waiting for other parse to finish...') 96 | table.insert(pending_parses[directory], fb.coroutine.callback(30)) 97 | list, opts = coroutine.yield() 98 | end 99 | 100 | local pending = pending_parses[directory] 101 | -- need to clear the pending parses before resuming them or they will also attempt to resume the parses 102 | pending_parses[directory] = nil 103 | if pending and #pending > 0 then 104 | msg.debug('resuming', #pending, 'pending parses for', directory) 105 | for _, cb in ipairs(pending) do 106 | cb(list, opts) 107 | end 108 | end 109 | 110 | if not list then return end 111 | 112 | -- pending will be truthy for the original parse and falsy for any parses that were pending 113 | if pending then 114 | msg.debug('storing', directory, 'contents in cache') 115 | cache[directory] = { 116 | list = list, 117 | opts = opts, 118 | timeout = mp.add_timeout(120, function() cache[directory] = nil end), 119 | } 120 | end 121 | 122 | return list, opts 123 | end 124 | 125 | cacheParser.keybinds = { 126 | { 127 | key = 'Ctrl+Shift+r', 128 | name = 'clear', 129 | command = function() clear_cache() ; fb.rescan() end, 130 | } 131 | } 132 | 133 | -- provide method of clearing the cache through script messages 134 | mp.register_script_message('cache/clear', function(dirs) 135 | if not dirs then 136 | return clear_cache() 137 | end 138 | 139 | ---@type string[]? 140 | local directories = utils.parse_json(dirs) 141 | if not directories then msg.error('unable to parse', dirs) end 142 | 143 | clear_cache(directories) 144 | end) 145 | 146 | return cacheParser 147 | -------------------------------------------------------------------------------- /modules/addons/file.lua: -------------------------------------------------------------------------------- 1 | -- This file is an internal file-browser addon. 2 | -- It should not be imported like a normal module. 3 | 4 | local msg = require 'mp.msg' 5 | local utils = require 'mp.utils' 6 | 7 | ---Parser for native filesystems 8 | ---@type ParserConfig 9 | local file_parser = { 10 | name = "file", 11 | priority = 110, 12 | api_version = '1.0.0', 13 | } 14 | 15 | --try to parse any directory except for the root 16 | function file_parser:can_parse(directory) 17 | return directory ~= '' 18 | end 19 | 20 | --scans the given directory using the mp.utils.readdir function 21 | function file_parser:parse(directory) 22 | local new_list = {} 23 | local list1 = utils.readdir(directory, 'dirs') 24 | if list1 == nil then return nil end 25 | 26 | --sorts folders and formats them into the list of directories 27 | for i=1, #list1 do 28 | local item = list1[i] 29 | 30 | msg.trace(item..'/') 31 | table.insert(new_list, {name = item..'/', type = 'dir'}) 32 | end 33 | 34 | --appends files to the list of directory items 35 | local list2 = utils.readdir(directory, 'files') 36 | if list2 == nil then return nil end 37 | for i=1, #list2 do 38 | local item = list2[i] 39 | 40 | msg.trace(item) 41 | table.insert(new_list, {name = item, type = 'file'}) 42 | end 43 | return new_list 44 | end 45 | 46 | return file_parser 47 | -------------------------------------------------------------------------------- /modules/addons/find.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file is an internal file-browser addon. 3 | It should not be imported like a normal module. 4 | 5 | Allows searching the current directory. 6 | ]]-- 7 | 8 | local msg = require "mp.msg" 9 | local fb = require "file-browser" 10 | local input_loaded, input = pcall(require, "mp.input") 11 | local user_input_loaded, user_input = pcall(require, "user-input-module") 12 | 13 | ---@type ParserConfig 14 | local find = { 15 | api_version = "1.3.0" 16 | } 17 | 18 | ---@type thread|nil 19 | local latest_coroutine = nil 20 | 21 | ---@type State 22 | local global_fb_state = getmetatable(fb.get_state()).__original 23 | 24 | ---@param name string 25 | ---@param query string 26 | ---@return boolean 27 | local function compare(name, query) 28 | if name:find(query) then return true end 29 | if name:lower():find(query) then return true end 30 | if name:upper():find(query) then return true end 31 | 32 | return false 33 | end 34 | 35 | ---@async 36 | ---@param key Keybind 37 | ---@param state State 38 | ---@param co thread 39 | ---@return boolean? 40 | local function main(key, state, co) 41 | if not state.list then return false end 42 | 43 | ---@type string 44 | local text 45 | if key.name == "find/find" then text = "Find: enter search string" 46 | else text = "Find: enter advanced search string" end 47 | 48 | if input_loaded then 49 | input.get({ 50 | prompt = text .. "\n>", 51 | id = "file-browser/find", 52 | submit = fb.coroutine.callback(), 53 | }) 54 | elseif user_input_loaded then 55 | user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } ) 56 | end 57 | 58 | local query, error = coroutine.yield() 59 | if input_loaded then input.terminate() end 60 | if not query then return msg.debug(error) end 61 | 62 | -- allow the directory to be changed before this point 63 | local list = fb.get_list() 64 | local parse_id = global_fb_state.co 65 | 66 | if key.name == "find/find" then 67 | query = fb.pattern_escape(query) 68 | end 69 | 70 | local results = {} 71 | 72 | for index, item in ipairs(list) do 73 | if compare(item.label or item.name, query) then 74 | table.insert(results, index) 75 | end 76 | end 77 | 78 | if (#results < 1) then 79 | msg.warn("No matching items for '"..query.."'") 80 | return 81 | end 82 | 83 | --keep cycling through the search results if any are found 84 | --putting this into a separate coroutine removes any passthrough ambiguity 85 | --the final return statement should return to `step_find` not any other function 86 | ---@async 87 | fb.coroutine.run(function() 88 | latest_coroutine = coroutine.running() 89 | ---@type number 90 | local rindex = 1 91 | while (true) do 92 | 93 | if rindex == 0 then rindex = #results 94 | elseif rindex == #results + 1 then rindex = 1 end 95 | 96 | fb.set_selected_index(results[rindex]) 97 | local direction = coroutine.yield(true) --[[@as number]] 98 | rindex = rindex + direction 99 | 100 | if parse_id ~= global_fb_state.co then 101 | latest_coroutine = nil 102 | return 103 | end 104 | end 105 | end) 106 | end 107 | 108 | local function step_find(key) 109 | if not latest_coroutine then return false end 110 | ---@type number 111 | local direction = 0 112 | if key.name == "find/next" then direction = 1 113 | elseif key.name == "find/prev" then direction = -1 end 114 | return fb.coroutine.resume_err(latest_coroutine, direction) 115 | end 116 | 117 | find.keybinds = { 118 | {"Ctrl+f", "find", main, {}}, 119 | {"Ctrl+F", "find_advanced", main, {}}, 120 | {"n", "next", step_find, {}}, 121 | {"N", "prev", step_find, {}}, 122 | } 123 | 124 | return find 125 | -------------------------------------------------------------------------------- /modules/addons/home-label.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which displays ~/ for the home directory instead of the full path 3 | ]]-- 4 | 5 | local mp = require "mp" 6 | local fb = require "file-browser" 7 | 8 | local home = fb.fix_path(mp.command_native({"expand-path", "~/"}) --[[@as string]], true) 9 | 10 | ---@type ParserConfig 11 | local home_label = { 12 | priority = 100, 13 | api_version = "1.0.0" 14 | } 15 | 16 | function home_label:can_parse(directory) 17 | if not fb.get_opt('home_label') then return false end 18 | return directory:sub(1, home:len()) == home 19 | end 20 | 21 | ---@async 22 | function home_label:parse(directory) 23 | local list, opts = self:defer(directory) 24 | if not opts then opts = {} end 25 | if (not opts.directory or opts.directory == directory) and not opts.directory_label then 26 | opts.directory_label = "~/"..(directory:sub(home:len()+1) or "") 27 | end 28 | return list, opts 29 | end 30 | 31 | return home_label -------------------------------------------------------------------------------- /modules/addons/last-opened-directory.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which stores the last opened directory and 3 | sets it as the opened directory the next time mpv is opened. 4 | 5 | Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons 6 | ]]-- 7 | 8 | local mp = require 'mp' 9 | local msg = require 'mp.msg' 10 | 11 | local fb = require 'file-browser' 12 | 13 | local state_file = mp.command_native({'expand-path', fb.get_opt('last_opened_directory_file')}) --[[@as string]] 14 | msg.verbose('using', state_file) 15 | 16 | ---@param directory? string 17 | ---@return nil 18 | local function write_directory(directory) 19 | if not fb.get_opt('save_last_opened_directory') then return end 20 | 21 | local file = io.open(state_file, 'w+') 22 | 23 | if not file then return msg.error('could not open', state_file, 'for writing') end 24 | 25 | directory = directory or fb.get_directory() or '' 26 | msg.verbose('writing', directory, 'to', state_file) 27 | file:write(directory) 28 | file:close() 29 | end 30 | 31 | ---@type ParserConfig 32 | local addon = { 33 | api_version = '1.7.0', 34 | priority = 0, 35 | } 36 | 37 | function addon:setup() 38 | if not fb.get_opt('default_to_last_opened_directory') then return end 39 | 40 | local file = io.open(state_file, "r") 41 | if not file then 42 | return msg.info('failed to open', state_file, 'for reading (may be due to first load)') 43 | end 44 | 45 | local dir = file:read("*a") 46 | msg.verbose('setting default directory to', dir) 47 | fb.browse_directory(dir, false) 48 | file:close() 49 | end 50 | 51 | function addon:can_parse(dir, parse_state) 52 | if parse_state.source == 'browser' then write_directory(dir) end 53 | return false 54 | end 55 | 56 | function addon:parse() 57 | return nil 58 | end 59 | 60 | mp.register_event('shutdown', function() write_directory() end) 61 | 62 | return addon 63 | -------------------------------------------------------------------------------- /modules/addons/ls.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which uses the Linux ls command to parse native directories 3 | This behaves near identically to the native parser, but IO is done asynchronously. 4 | 5 | Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons 6 | ]]-- 7 | 8 | local mp = require "mp" 9 | local msg = require "mp.msg" 10 | local fb = require "file-browser" 11 | 12 | local PLATFORM = fb.get_platform() 13 | 14 | ---@type ParserConfig 15 | local ls = { 16 | priority = 109, 17 | api_version = "1.9.0", 18 | name = "ls", 19 | keybind_name = "file" 20 | } 21 | 22 | ---@async 23 | ---@param args string[] 24 | ---@param parse_state ParseState 25 | ---@return string|nil 26 | local function command(args, parse_state) 27 | local async = mp.command_native_async({ 28 | name = "subprocess", 29 | playback_only = false, 30 | capture_stdout = true, 31 | capture_stderr = true, 32 | args = args 33 | }, fb.coroutine.callback(30)) 34 | 35 | ---@type boolean, boolean, MPVSubprocessResult 36 | local completed, _, cmd = parse_state:yield() 37 | if not completed then 38 | msg.warn('read timed out for:', table.unpack(args)) 39 | mp.abort_async_command(async) 40 | return nil 41 | end 42 | 43 | return cmd.status == 0 and cmd.stdout or nil 44 | end 45 | 46 | function ls:can_parse(directory) 47 | if not fb.get_opt('ls_parser') then return false end 48 | return PLATFORM ~= 'windows' and directory ~= '' and not fb.get_protocol(directory) 49 | end 50 | 51 | ---@async 52 | function ls:parse(directory, parse_state) 53 | local list = {} 54 | local files = command({"ls", "-1", "-p", "-A", "-N", "--zero", "-L", directory}, parse_state) 55 | 56 | if not files then return nil end 57 | 58 | for str in files:gmatch("%Z+") do 59 | local is_dir = str:sub(-1) == "/" 60 | msg.trace(str) 61 | 62 | table.insert(list, {name = str, type = is_dir and "dir" or "file"}) 63 | end 64 | 65 | return list 66 | end 67 | 68 | return ls 69 | -------------------------------------------------------------------------------- /modules/addons/root.lua: -------------------------------------------------------------------------------- 1 | -- This file is an internal file-browser addon. 2 | -- It should not be imported like a normal module. 3 | 4 | local g = require 'modules.globals' 5 | 6 | ---Parser for the root. 7 | ---@type ParserConfig 8 | local root_parser = { 9 | name = "root", 10 | priority = math.huge, 11 | api_version = '1.0.0', 12 | } 13 | 14 | function root_parser:can_parse(directory) 15 | return directory == '' 16 | end 17 | 18 | --we return the root directory exactly as setup 19 | function root_parser:parse() 20 | return g.root, { 21 | sorted = true, 22 | filtered = true, 23 | } 24 | end 25 | 26 | return root_parser 27 | -------------------------------------------------------------------------------- /modules/addons/windir.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | An addon for mpv-file-browser which uses the Windows dir command to parse native directories 3 | This behaves near identically to the native parser, but IO is done asynchronously. 4 | 5 | Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons 6 | ]]-- 7 | 8 | local mp = require "mp" 9 | local msg = require "mp.msg" 10 | local fb = require "file-browser" 11 | 12 | local PLATFORM = fb.get_platform() 13 | 14 | ---@param bytes string 15 | ---@return fun(): number, number 16 | local function byte_iterator(bytes) 17 | ---@async 18 | ---@return number? 19 | local function iter() 20 | for i = 1, #bytes do 21 | coroutine.yield(bytes:byte(i), i) 22 | end 23 | error('malformed utf16le string - expected byte but found end of string') 24 | end 25 | 26 | return coroutine.wrap(iter) 27 | end 28 | 29 | ---@param bits number 30 | ---@param by number 31 | ---@return number 32 | local function lshift(bits, by) 33 | return bits * 2^by 34 | end 35 | 36 | ---@param bits number 37 | ---@param by number 38 | ---@return integer 39 | local function rshift(bits, by) 40 | return math.floor(bits / 2^by) 41 | end 42 | 43 | ---@param bits number 44 | ---@param i number 45 | ---@return number 46 | local function bits_below(bits, i) 47 | return bits % 2^i 48 | end 49 | 50 | ---@param bits number 51 | ---@param i number exclusive 52 | ---@param j number inclusive 53 | ---@return integer 54 | local function bits_between(bits, i, j) 55 | return rshift(bits_below(bits, j), i) 56 | end 57 | 58 | ---@param bytes string 59 | ---@return number[] 60 | local function utf16le_to_unicode(bytes) 61 | msg.trace('converting from utf16-le to unicode codepoints') 62 | 63 | ---@type number[] 64 | local codepoints = {} 65 | 66 | local get_byte = byte_iterator(bytes) 67 | 68 | while true do 69 | -- start of a char 70 | local success, little, i = pcall(get_byte) 71 | if not success then break end 72 | 73 | local big = get_byte() 74 | local codepoint = little + lshift(big, 8) 75 | 76 | if codepoint < 0xd800 or codepoint > 0xdfff then 77 | table.insert(codepoints, codepoint) 78 | else 79 | -- handling surrogate pairs 80 | -- grab the next two bytes to grab the low surrogate 81 | local high_pair = codepoint 82 | local low_pair = get_byte() + lshift(get_byte(), 8) 83 | 84 | if high_pair >= 0xdc00 then 85 | error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair)) 86 | elseif low_pair < 0xdc00 then 87 | error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair)) 88 | end 89 | 90 | -- The last 10 bits of each surrogate are the two halves of the codepoint 91 | -- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF 92 | local high_bits = bits_below(high_pair, 10) 93 | local low_bits = bits_below(low_pair, 10) 94 | local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000 95 | 96 | table.insert(codepoints, surrogate_par) 97 | end 98 | end 99 | 100 | return codepoints 101 | end 102 | 103 | ---@param codepoints number[] 104 | ---@return string 105 | local function unicode_to_utf8(codepoints) 106 | ---@type number[] 107 | local bytes = {} 108 | 109 | -- https://en.wikipedia.org/wiki/UTF-8#Description 110 | for i, codepoint in ipairs(codepoints) do 111 | if codepoint >= 0xd800 and codepoint <= 0xdfff then 112 | error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint)) 113 | elseif codepoint <= 0x7f then 114 | table.insert(bytes, codepoint) 115 | elseif codepoint <= 0x7ff then 116 | table.insert(bytes, 0xC0 + rshift(codepoint, 6)) 117 | table.insert(bytes, 0x80 + bits_below(codepoint, 6)) 118 | elseif codepoint <= 0xffff then 119 | table.insert(bytes, 0xE0 + rshift(codepoint, 12)) 120 | table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12)) 121 | table.insert(bytes, 0x80 + bits_below(codepoint, 6)) 122 | elseif codepoint <= 0x10ffff then 123 | table.insert(bytes, 0xF0 + rshift(codepoint, 18)) 124 | table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18)) 125 | table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12)) 126 | table.insert(bytes, 0x80 + bits_below(codepoint, 6)) 127 | else 128 | error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint)) 129 | end 130 | end 131 | 132 | return string.char(table.unpack(bytes)) 133 | end 134 | 135 | local function utf8(text) 136 | return unicode_to_utf8(utf16le_to_unicode(text)) 137 | end 138 | 139 | ---@type ParserConfig 140 | local dir = { 141 | priority = 109, 142 | api_version = "1.9.0", 143 | name = "cmd-dir", 144 | keybind_name = "file" 145 | } 146 | 147 | ---@async 148 | ---@param args string[] 149 | ---@param parse_state ParseState 150 | ---@return string|nil 151 | local function command(args, parse_state) 152 | local async = mp.command_native_async({ 153 | name = "subprocess", 154 | playback_only = false, 155 | capture_stdout = true, 156 | capture_stderr = true, 157 | args = args, 158 | }, fb.coroutine.callback(30) ) 159 | 160 | ---@type boolean, boolean, MPVSubprocessResult 161 | local completed, _, cmd = parse_state:yield() 162 | if not completed then 163 | msg.warn('read timed out for:', table.unpack(args)) 164 | mp.abort_async_command(async) 165 | return nil 166 | end 167 | 168 | local success = xpcall(function() 169 | cmd.stdout = utf8(cmd.stdout) or '' 170 | cmd.stderr = utf8(cmd.stderr) or '' 171 | end, fb.traceback) 172 | 173 | if not success then return msg.error('failed to convert utf16-le string to utf8') end 174 | 175 | --dir returns this exact error message if the directory is empty 176 | if cmd.status == 1 and cmd.stderr == "File Not Found\r\n" then cmd.status = 0 end 177 | if cmd.status ~= 0 then return msg.error(cmd.stderr) end 178 | 179 | return cmd.status == 0 and cmd.stdout or nil 180 | end 181 | 182 | function dir:can_parse(directory) 183 | if not fb.get_opt('windir_parser') then return false end 184 | return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory) 185 | end 186 | 187 | ---@async 188 | function dir:parse(directory, parse_state) 189 | local list = {} 190 | 191 | -- the dir command expects backslashes for our paths 192 | directory = string.gsub(directory, "/", "\\") 193 | 194 | local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state) 195 | if not dirs then return end 196 | 197 | local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state) 198 | if not files then return end 199 | 200 | for name in dirs:gmatch("[^\n\r]+") do 201 | name = name.."/" 202 | if fb.valid_dir(name) then 203 | table.insert(list, { name = name, type = "dir" }) 204 | msg.trace(name) 205 | end 206 | end 207 | 208 | for name in files:gmatch("[^\n\r]+") do 209 | if fb.valid_file(name) then 210 | table.insert(list, { name = name, type = "file" }) 211 | msg.trace(name) 212 | end 213 | end 214 | 215 | return list, { filtered = true } 216 | end 217 | 218 | return dir 219 | -------------------------------------------------------------------------------- /modules/addons/winroot.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file is an internal file-browser addon. 3 | It should not be imported like a normal module. 4 | 5 | Automatically populates the root with windows drives on startup. 6 | Ctrl+r will add new drives mounted since startup. 7 | 8 | Drives will only be added if they are not already present in the root. 9 | ]] 10 | 11 | local mp = require 'mp' 12 | local msg = require 'mp.msg' 13 | local fb = require 'file-browser' 14 | 15 | local PLATFORM = fb.get_platform() 16 | 17 | ---returns a list of windows drives 18 | ---@return string[]? 19 | local function get_drives() 20 | ---@type MPVSubprocessResult?, string? 21 | local result, err = mp.command_native({ 22 | name = 'subprocess', 23 | playback_only = false, 24 | capture_stdout = true, 25 | args = {'fsutil', 'fsinfo', 'drives'} 26 | }) 27 | if not result then return msg.error(err) end 28 | if result.status ~= 0 then return msg.error('could not read windows root') end 29 | 30 | local root = {} 31 | for drive in result.stdout:gmatch("(%a:)\\") do 32 | table.insert(root, drive..'/') 33 | end 34 | return root 35 | end 36 | 37 | -- adds windows drives to the root if they are not already present 38 | local function import_drives() 39 | if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end 40 | 41 | local drives = get_drives() 42 | if not drives then return end 43 | 44 | for _, drive in ipairs(drives) do 45 | fb.register_root_item(drive) 46 | end 47 | end 48 | 49 | local keybind = { 50 | key = 'Ctrl+r', 51 | name = 'import_root_drives', 52 | command = import_drives, 53 | parser = 'root', 54 | passthrough = true 55 | } 56 | 57 | ---@type ParserConfig 58 | return { 59 | api_version = '1.9.0', 60 | setup = import_drives, 61 | keybinds = { keybind } 62 | } 63 | -------------------------------------------------------------------------------- /modules/apis/fb.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | local utils = require 'mp.utils' 4 | 5 | local o = require 'modules.options' 6 | local g = require 'modules.globals' 7 | local fb_utils = require 'modules.utils' 8 | local ass = require 'modules.ass' 9 | local directory_movement = require 'modules.navigation.directory-movement' 10 | local scanning = require 'modules.navigation.scanning' 11 | local controls = require 'modules.controls' 12 | 13 | ---@class FbAPI: fb_utils 14 | local fb = setmetatable({}, { __index = setmetatable({}, { __index = fb_utils }) }) 15 | package.loaded["file-browser"] = setmetatable({}, { __index = fb }) 16 | 17 | --these functions we'll provide as-is 18 | fb.redraw = ass.update_ass 19 | fb.browse_directory = controls.browse_directory 20 | 21 | ---Clears the directory cache. 22 | ---@return thread 23 | function fb.rescan() 24 | return scanning.rescan() 25 | end 26 | 27 | ---@async 28 | ---@return thread 29 | function fb.rescan_await() 30 | local co = scanning.rescan(nil, fb_utils.coroutine.callback()) 31 | coroutine.yield() 32 | return co 33 | end 34 | 35 | ---@param directories? string[] 36 | function fb.clear_cache(directories) 37 | if directories then 38 | mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear', utils.format_json(directories)) 39 | else 40 | mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear') 41 | end 42 | end 43 | 44 | ---A wrapper around scan_directory for addon API. 45 | ---@async 46 | ---@param directory string 47 | ---@param parse_state ParseStateTemplate 48 | ---@return Item[]|nil 49 | ---@return Opts 50 | function fb.parse_directory(directory, parse_state) 51 | if not parse_state then parse_state = { source = "addon" } 52 | elseif not parse_state.source then parse_state.source = "addon" end 53 | return scanning.scan_directory(directory, parse_state) 54 | end 55 | 56 | ---Register file extensions which can be opened by the browser. 57 | ---@param ext string 58 | function fb.register_parseable_extension(ext) 59 | g.parseable_extensions[string.lower(ext)] = true 60 | end 61 | 62 | ---Deregister file extensions which can be opened by the browser. 63 | ---@param ext string 64 | function fb.remove_parseable_extension(ext) 65 | g.parseable_extensions[string.lower(ext)] = nil 66 | end 67 | 68 | ---Add a compatible extension to show through the filter, only applies if run during the setup() method. 69 | ---@param ext string 70 | function fb.add_default_extension(ext) 71 | table.insert(g.compatible_file_extensions, ext) 72 | end 73 | 74 | ---Add item to root at position pos. 75 | ---@param item Item 76 | ---@param pos? number 77 | function fb.insert_root_item(item, pos) 78 | msg.debug("adding item to root", item.label or item.name, pos) 79 | item.ass = item.ass or fb.ass_escape(item.label or item.name) 80 | item.type = "dir" 81 | table.insert(g.root, pos or (#g.root + 1), item) 82 | end 83 | 84 | ---Add a new mapping to the given directory. 85 | ---@param directory string 86 | ---@param mapping string 87 | ---@param pattern? boolean 88 | ---@return string 89 | function fb.register_directory_mapping(directory, mapping, pattern) 90 | if not pattern then mapping = '^'..fb_utils.pattern_escape(mapping) end 91 | g.directory_mappings[mapping] = directory 92 | msg.verbose('registering directory alias', mapping, directory) 93 | 94 | directory_movement.set_current_file(g.current_file.original_path) 95 | return mapping 96 | end 97 | 98 | ---Remove all directory mappings that map to the given directory. 99 | ---@param directory string 100 | ---@return string[] 101 | function fb.remove_all_mappings(directory) 102 | local removed = {} 103 | for mapping, target in pairs(g.directory_mappings) do 104 | if target == directory then 105 | g.directory_mappings[mapping] = nil 106 | table.insert(removed, mapping) 107 | end 108 | end 109 | return removed 110 | end 111 | 112 | ---A newer API for adding items to the root. 113 | ---Only adds the item if the same item does not already exist in the root. 114 | ---@param item Item|string 115 | ---@param priority? number Specifies the insertion location, a lower priority 116 | --- is placed higher in the list and the default is 100. 117 | ---@return boolean 118 | function fb.register_root_item(item, priority) 119 | msg.verbose('registering root item:', utils.to_string(item)) 120 | if type(item) == 'string' then 121 | item = {name = item, type = 'dir'} 122 | end 123 | 124 | -- if the item is already in the list then do nothing 125 | if fb.list.some(g.root, function(r) 126 | return fb.get_full_path(r, '') == fb.get_full_path(item, '') 127 | end) then return false end 128 | 129 | ---@type table 130 | local priorities = {} 131 | 132 | priorities[item] = priority 133 | for i, v in ipairs(g.root) do 134 | if (priorities[v] or 100) > (priority or 100) then 135 | fb.insert_root_item(item, i) 136 | return true 137 | end 138 | end 139 | fb.insert_root_item(item) 140 | return true 141 | end 142 | 143 | --providing getter and setter functions so that addons can't modify things directly 144 | 145 | 146 | ---@param key string 147 | ---@return boolean|string|number 148 | function fb.get_opt(key) return o[key] end 149 | 150 | function fb.get_script_opts() return fb.copy_table(o) end 151 | function fb.get_platform() return g.PLATFORM end 152 | function fb.get_extensions() return fb.copy_table(g.extensions) end 153 | function fb.get_sub_extensions() return fb.copy_table(g.sub_extensions) end 154 | function fb.get_audio_extensions() return fb.copy_table(g.audio_extensions) end 155 | function fb.get_parseable_extensions() return fb.copy_table(g.parseable_extensions) end 156 | function fb.get_state() return fb.copy_table(g.state) end 157 | function fb.get_parsers() return fb.copy_table(g.parsers) end 158 | function fb.get_root() return fb.copy_table(g.root) end 159 | function fb.get_directory() return g.state.directory end 160 | function fb.get_list() return fb.copy_table(g.state.list) end 161 | function fb.get_current_file() return fb.copy_table(g.current_file) end 162 | function fb.get_current_parser() return g.state.parser:get_id() end 163 | function fb.get_current_parser_keyname() return g.state.parser.keybind_name or g.state.parser.name end 164 | function fb.get_selected_index() return g.state.selected end 165 | function fb.get_selected_item() return fb.copy_table(g.state.list[g.state.selected]) end 166 | function fb.get_open_status() return not g.state.hidden end 167 | function fb.get_parse_state(co) return g.parse_states[co or coroutine.running() or ""] end 168 | function fb.get_history() return fb.copy_table(g.history.list) end 169 | function fb.get_history_index() return g.history.position end 170 | 171 | ---@deprecated 172 | ---@return string|nil 173 | function fb.get_dvd_device() 174 | local dvd_device = mp.get_property('dvd-device') 175 | if not dvd_device or dvd_device == '' then return nil end 176 | return fb_utils.fix_path(dvd_device, true) 177 | end 178 | 179 | ---@param str string 180 | function fb.set_empty_text(str) 181 | g.state.empty_text = str 182 | fb.redraw() 183 | end 184 | 185 | ---@param index number 186 | ---@return number|false 187 | function fb.set_selected_index(index) 188 | if type(index) ~= "number" then return false end 189 | if index < 1 then index = 1 end 190 | if index > #g.state.list then index = #g.state.list end 191 | g.state.selected = index 192 | fb.redraw() 193 | return index 194 | end 195 | 196 | fb.set_history_index = directory_movement.goto_history 197 | 198 | return fb 199 | -------------------------------------------------------------------------------- /modules/apis/parse-state.lua: -------------------------------------------------------------------------------- 1 | 2 | local msg = require 'mp.msg' 3 | 4 | local g = require 'modules.globals' 5 | 6 | ---@class ParseStateAPI 7 | local parse_state_API = {} 8 | 9 | ---A wrapper around coroutine.yield that aborts the coroutine if 10 | --the parse request was cancelled by the user. 11 | --the coroutine is 12 | ---@async 13 | ---@param self ParseState 14 | ---@param ... any 15 | ---@return unknown ... 16 | function parse_state_API:yield(...) 17 | local co = coroutine.running() 18 | local is_browser = co == g.state.co 19 | 20 | local result = table.pack(coroutine.yield(...)) 21 | if is_browser and co ~= g.state.co then 22 | msg.verbose("browser no longer waiting for list - aborting parse for", self.directory) 23 | error(g.ABORT_ERROR) 24 | end 25 | return table.unpack(result, 1, result.n) 26 | end 27 | 28 | ---Checks if the current coroutine is the one handling the browser's request. 29 | ---@return boolean 30 | function parse_state_API:is_coroutine_current() 31 | return coroutine.running() == g.state.co 32 | end 33 | 34 | return parse_state_API 35 | -------------------------------------------------------------------------------- /modules/apis/parser.lua: -------------------------------------------------------------------------------- 1 | local msg = require 'mp.msg' 2 | 3 | local g = require 'modules.globals' 4 | local scanning = require 'modules.navigation.scanning' 5 | local fb = require 'modules.apis.fb' 6 | 7 | ---@class ParserAPI: FbAPI 8 | local parser_api = setmetatable({}, { __index = fb }) 9 | 10 | ---Returns the index of the parser. 11 | ---@return number 12 | function parser_api:get_index() return g.parsers[self].index end 13 | 14 | ---Returns the ID of the parser 15 | ---@return string 16 | function parser_api:get_id() return g.parsers[self].id end 17 | 18 | ---A newer API for adding items to the root. 19 | ---Only adds the item if the same item does not already exist in the root. 20 | ---Wrapper around `fb.register_root_item`. 21 | ---@param item Item|string 22 | ---@param priority? number The priority for the added item. Uses the parsers priority by default. 23 | ---@return boolean 24 | function parser_api:register_root_item(item, priority) 25 | return fb.register_root_item(item, priority or g.parsers[self:get_id()].priority) 26 | end 27 | 28 | ---Runs choose_and_parse starting from the next parser. 29 | ---@async 30 | ---@param directory string 31 | ---@return Item[]? 32 | ---@return Opts? 33 | function parser_api:defer(directory) 34 | msg.trace("deferring to other parsers...") 35 | local list, opts = scanning.choose_and_parse(directory, self:get_index() + 1) 36 | fb.get_parse_state().already_deferred = true 37 | return list, opts 38 | end 39 | 40 | return parser_api 41 | -------------------------------------------------------------------------------- /modules/ass.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------------------------------- 2 | -----------------------------------------List Formatting------------------------------------------------ 3 | -------------------------------------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------------------------------- 5 | 6 | local g = require 'modules.globals' 7 | local o = require 'modules.options' 8 | local fb_utils = require 'modules.utils' 9 | 10 | local state = g.state 11 | local style = g.style 12 | local ass = g.ass 13 | 14 | local function draw() 15 | ass:update() 16 | end 17 | 18 | local function remove() 19 | ass:remove() 20 | end 21 | 22 | ---@type string[] 23 | local string_buffer = {} 24 | 25 | ---appends the entered text to the overlay 26 | ---@param ... string 27 | local function append(...) 28 | for i = 1, select("#", ...) do 29 | table.insert(string_buffer, select(i, ...) or '' ) 30 | end 31 | end 32 | 33 | --appends a newline character to the osd 34 | local function newline() 35 | table.insert(string_buffer, '\\N') 36 | end 37 | 38 | local function flush_buffer() 39 | ass.data = table.concat(string_buffer, '') 40 | string_buffer = {} 41 | end 42 | 43 | ---detects whether or not to highlight the given entry as being played 44 | ---@param v Item 45 | ---@return boolean 46 | local function highlight_entry(v) 47 | if g.current_file.path == nil then return false end 48 | local full_path = fb_utils.get_full_path(v) 49 | local alt_path = v.name and g.state.directory..v.name or nil 50 | 51 | if fb_utils.parseable_item(v) then 52 | return ( 53 | string.find(g.current_file.directory, full_path, 1, true) 54 | or (alt_path and string.find(g.current_file.directory, alt_path, 1, true)) 55 | ) ~= nil 56 | else 57 | return g.current_file.path == full_path 58 | or (alt_path and g.current_file.path == alt_path) 59 | end 60 | end 61 | 62 | ---escape ass values and replace newlines 63 | ---@param str string 64 | ---@param style_reset string? 65 | ---@return string 66 | local function ass_escape(str, style_reset) 67 | return fb_utils.ass_escape(str, style_reset and style.warning..'␊'..style_reset or true) 68 | end 69 | 70 | local header_overrides = {['^'] = style.header} 71 | 72 | ---@return number start 73 | ---@return number finish 74 | ---@return boolean is_overflowing 75 | local function calculate_view_window() 76 | ---@type number 77 | local start = 1 78 | ---@type number 79 | local finish = start+o.num_entries-1 80 | 81 | --handling cursor positioning 82 | local mid = math.ceil(o.num_entries/2)+1 83 | if state.selected+mid > finish then 84 | ---@type number 85 | local offset = state.selected - finish + mid 86 | 87 | --if we've overshot the end of the list then undo some of the offset 88 | if finish + offset > #state.list then 89 | offset = offset - ((finish+offset) - #state.list) 90 | end 91 | 92 | start = start + offset 93 | finish = finish + offset 94 | end 95 | 96 | --making sure that we don't overstep the boundaries 97 | if start < 1 then start = 1 end 98 | local overflow = finish < #state.list 99 | --this is necessary when the number of items in the dir is less than the max 100 | if not overflow then finish = #state.list end 101 | 102 | return start, finish, overflow 103 | end 104 | 105 | ---@param i number index 106 | ---@return string 107 | local function calculate_item_style(i) 108 | local is_playing_file = highlight_entry(state.list[i]) 109 | 110 | --sets the selection colour scheme 111 | local multiselected = state.selection[i] 112 | 113 | --sets the colour for the item 114 | local item_style = style.body 115 | 116 | if multiselected then item_style = item_style..style.multiselect 117 | elseif i == state.selected then item_style = item_style..style.selected end 118 | 119 | if is_playing_file then item_style = item_style..(multiselected and style.playing_selected or style.playing) end 120 | 121 | return item_style 122 | end 123 | 124 | local function draw_header() 125 | append(style.header) 126 | append(fb_utils.substitute_codes(o.format_string_header, header_overrides, nil, nil, function(str, code) 127 | if code == '^' then return str end 128 | return ass_escape(str, style.header) 129 | end)) 130 | newline() 131 | end 132 | 133 | ---@param wrapper_overrides ReplacerTable 134 | local function draw_top_wrapper(wrapper_overrides) 135 | --adding a header to show there are items above in the list 136 | append(style.footer_header) 137 | append(fb_utils.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, function(str) 138 | return ass_escape(str) 139 | end)) 140 | newline() 141 | end 142 | 143 | ---@param wrapper_overrides ReplacerTable 144 | local function draw_bottom_wrapper(wrapper_overrides) 145 | append(style.footer_header) 146 | append(fb_utils.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, function(str) 147 | return ass_escape(str) 148 | end)) 149 | end 150 | 151 | ---@param i number index 152 | ---@param cursor string 153 | local function draw_cursor(i, cursor) 154 | --handles custom styles for different entries 155 | if i == state.selected or i == state.multiselect_start then 156 | if not (i == state.selected) then append(style.selection_marker) end 157 | 158 | if not state.multiselect_start then append(style.cursor) 159 | else 160 | if state.selection[state.multiselect_start] then append(style.cursor_select) 161 | else append(style.cursor_deselect) end 162 | end 163 | else 164 | append(g.style.indent) 165 | end 166 | append(cursor, '\\h', style.body) 167 | end 168 | 169 | --refreshes the ass text using the contents of the list 170 | local function update_ass() 171 | if state.hidden then state.flag_update = true ; return end 172 | 173 | append(style.global) 174 | draw_header() 175 | 176 | if #state.list < 1 then 177 | append(state.empty_text) 178 | flush_buffer() 179 | draw() 180 | return 181 | end 182 | 183 | local start, finish, overflow = calculate_view_window() 184 | 185 | -- these are the number values to place into the wrappers 186 | local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)} 187 | if o.format_string_topwrapper ~= '' and start > 1 then 188 | draw_top_wrapper(wrapper_overrides) 189 | end 190 | 191 | for i=start, finish do 192 | local v = state.list[i] 193 | append(style.body) 194 | if g.ALIGN_X ~= 'right' then draw_cursor(i, o.cursor_icon) end 195 | 196 | local item_style = calculate_item_style(i) 197 | append(item_style) 198 | 199 | --sets the folder icon 200 | if v.type == 'dir' then 201 | append(style.folder, o.folder_icon, "\\h", style.body) 202 | append(item_style) 203 | end 204 | 205 | --adds the actual name of the item 206 | append(v.ass or ass_escape(v.label or v.name, item_style), '\\h') 207 | if g.ALIGN_X == 'right' then draw_cursor(i, o.cursor_icon_flipped) end 208 | newline() 209 | end 210 | 211 | if o.format_string_bottomwrapper ~= '' and overflow then 212 | draw_bottom_wrapper(wrapper_overrides) 213 | end 214 | 215 | flush_buffer() 216 | draw() 217 | end 218 | 219 | ---@class ass 220 | return { 221 | update_ass = update_ass, 222 | highlight_entry = highlight_entry, 223 | draw = draw, 224 | remove = remove, 225 | } 226 | -------------------------------------------------------------------------------- /modules/controls.lua: -------------------------------------------------------------------------------- 1 | 2 | local mp = require 'mp' 3 | local msg = require 'mp.msg' 4 | local utils = require 'mp.utils' 5 | 6 | local o = require 'modules.options' 7 | local g = require 'modules.globals' 8 | local fb_utils = require 'modules.utils' 9 | local movement = require 'modules.navigation.directory-movement' 10 | local ass = require 'modules.ass' 11 | local cursor = require 'modules.navigation.cursor' 12 | 13 | ---@class controls 14 | local controls = {} 15 | 16 | --opens the browser 17 | function controls.open() 18 | if not g.state.hidden then return end 19 | 20 | for _,v in ipairs(g.state.keybinds) do 21 | mp.add_forced_key_binding(v[1], 'dynamic/'..v[2], v[3], v[4]) 22 | end 23 | 24 | if o.set_shared_script_properties then utils.shared_script_property_set('file_browser-open', 'yes') end ---@diagnostic disable-line deprecated 25 | if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', true) end 26 | 27 | if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'no', 'no_osd') end 28 | g.state.hidden = false 29 | if g.state.directory == nil then 30 | local path = mp.get_property('path') 31 | if path or o.default_to_working_directory then movement.goto_current_dir() else movement.goto_root() end 32 | return 33 | end 34 | 35 | if not g.state.flag_update then ass.draw() 36 | else g.state.flag_update = false ; ass.update_ass() end 37 | end 38 | 39 | --closes the list and sets the hidden flag 40 | function controls.close() 41 | if g.state.hidden then return end 42 | 43 | for _,v in ipairs(g.state.keybinds) do 44 | mp.remove_key_binding('dynamic/'..v[2]) 45 | end 46 | 47 | if o.set_shared_script_properties then utils.shared_script_property_set("file_browser-open", "no") end ---@diagnostic disable-line deprecated 48 | if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', false) end 49 | 50 | if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'yes', 'no_osd') end 51 | g.state.hidden = true 52 | ass.remove() 53 | end 54 | 55 | --toggles the list 56 | function controls.toggle() 57 | if g.state.hidden then controls.open() 58 | else controls.close() end 59 | end 60 | 61 | --run when the escape key is used 62 | function controls.escape() 63 | --if multiple items are selection cancel the 64 | --selection instead of closing the browser 65 | if next(g.state.selection) or g.state.multiselect_start then 66 | g.state.selection = {} 67 | cursor.disable_select_mode() 68 | ass.update_ass() 69 | return 70 | end 71 | controls.close() 72 | end 73 | 74 | ---opens a specific directory 75 | ---@param directory string 76 | ---@param open_browser? boolean 77 | ---@return thread|nil 78 | function controls.browse_directory(directory, open_browser) 79 | if not directory then return end 80 | if open_browser == nil then open_browser = true end 81 | 82 | directory = mp.command_native({"expand-path", directory}, '') --[[@as string]] 83 | -- directory = join_path( mp.get_property("working-directory", ""), directory ) 84 | 85 | if directory ~= "" then directory = fb_utils.fix_path(directory, true) end 86 | msg.verbose('recieved directory from script message: '..directory) 87 | 88 | directory = fb_utils.resolve_directory_mapping(directory) 89 | local co = movement.goto_directory(directory, nil, nil, {cache={use=false}}) 90 | if open_browser then controls.open() end 91 | return co 92 | end 93 | 94 | return controls 95 | -------------------------------------------------------------------------------- /modules/defs/file-browser.lua: -------------------------------------------------------------------------------- 1 | ---@meta file-browser 2 | 3 | return require 'modules.apis.fb' 4 | -------------------------------------------------------------------------------- /modules/defs/keybind.lua: -------------------------------------------------------------------------------- 1 | ---@meta _ 2 | 3 | ---@class KeybindFlags 4 | ---@field repeatable boolean? 5 | ---@field scalable boolean? 6 | ---@field complex boolean? 7 | 8 | 9 | ---@class KeybindCommandTable 10 | 11 | 12 | ---@class Keybind 13 | ---@field key string 14 | ---@field command KeybindCommand 15 | ---@field api_version string? 16 | --- 17 | ---@field name string? 18 | ---@field condition string? 19 | ---@field flags KeybindFlags? 20 | ---@field filter ('file'|'dir')? 21 | ---@field parser string? 22 | ---@field multiselect boolean? 23 | ---@field multi-type ('repeat'|'concat')? 24 | ---@field delay number? 25 | ---@field concat-string string? 26 | ---@field passthrough boolean? 27 | --- 28 | ---@field prev_key Keybind? The keybind that was previously set to the same key. 29 | ---@field codes Set? Any substituation codes used by the command table. 30 | ---@field condition_codes Set? Any substitution codes used by the condition string. 31 | ---@field addon boolean? Whether the keybind was created by an addon. 32 | 33 | 34 | ---@alias KeybindFunctionCallback async fun(keybind: Keybind, state: State, co: thread) 35 | 36 | ---@alias KeybindCommand KeybindFunctionCallback|KeybindCommandTable[] 37 | ---@alias KeybindTuple [string,string,KeybindCommand,KeybindFlags?] 38 | ---@alias KeybindTupleStrict [string,string,KeybindFunctionCallback,KeybindFlags?] 39 | ---@alias KeybindList (Keybind|KeybindTuple)[] 40 | -------------------------------------------------------------------------------- /modules/defs/list.lua: -------------------------------------------------------------------------------- 1 | ---@meta _ 2 | 3 | ---@alias List Item[] 4 | 5 | ---Represents an item returned by the parsers. 6 | ---@class Item 7 | ---@field type 'file'|'dir' 8 | ---@field name string 9 | ---@field label string? 10 | ---@field path string? 11 | ---@field ass string? 12 | ---@field redirect boolean? 13 | ---@field mpv_options string|{[string]: unknown}? 14 | 15 | 16 | ---The Opts table returned by the parsers. 17 | ---@class Opts 18 | ---@field filtered boolean? 19 | ---@field sorted boolean? 20 | ---@field directory string? 21 | ---@field directory_label string? 22 | ---@field empty_text string? 23 | ---@field selected_index number? 24 | ---@field id string? 25 | ---@field parser Parser? -------------------------------------------------------------------------------- /modules/defs/mp/defaults.lua: -------------------------------------------------------------------------------- 1 | ---@meta mp 2 | 3 | ---@class mp 4 | local mp = {} 5 | 6 | ---@class AsyncReturn 7 | 8 | ---@class MPTimer 9 | ---@field stop fun(self: MPTimer) 10 | ---@field kill fun(self: MPTimer) 11 | ---@field resume fun(self: MPTimer) 12 | ---@field is_enabled fun(self: MPTimer): boolean 13 | ---@field timeout number 14 | ---@field oneshot boolean 15 | 16 | ---@class OSDOverlay 17 | ---@field data string 18 | ---@field res_x number 19 | ---@field res_y number 20 | ---@field z number 21 | ---@field update fun(self:OSDOverlay) 22 | ---@field remove fun(self: OSDOverlay) 23 | 24 | ---@class MPVSubprocessResult 25 | ---@field status number 26 | ---@field stdout string 27 | ---@field stderr string 28 | ---@field error_string ''|'killed'|'init' 29 | ---@field killed_by_us boolean 30 | 31 | ---@param key string 32 | ---@param name_or_fn string|function 33 | ---@param fn? async fun() 34 | ---@param flags? KeybindFlags 35 | function mp.add_key_binding(key, name_or_fn, fn, flags) end 36 | 37 | ---@param key string 38 | ---@param name_or_fn string|function 39 | ---@param fn? async fun() 40 | ---@param flags? KeybindFlags 41 | function mp.add_forced_key_binding(key, name_or_fn, fn, flags) end 42 | 43 | ---@param seconds number 44 | ---@param fn function 45 | ---@param disabled? boolean 46 | ---@return MPTimer 47 | function mp.add_timeout(seconds, fn, disabled) end 48 | 49 | ---@param format 'ass-events' 50 | ---@return OSDOverlay 51 | function mp.create_osd_overlay(format) end 52 | 53 | ---@param ... string 54 | function mp.commandv(...) end 55 | 56 | ---@generic T 57 | ---@param t table 58 | ---@param def? T 59 | ---@return unknown|T result 60 | ---@return string? error 61 | ---@overload fun(t: table): (unknown|nil, string?) 62 | function mp.command_native(t, def) end 63 | 64 | ---@nodiscard 65 | ---@param t table 66 | ---@param cb fun(success: boolean, result: unknown, error: string?) 67 | ---@return AsyncReturn 68 | function mp.command_native_async(t, cb) end 69 | 70 | ---@param t AsyncReturn 71 | function mp.abort_async_command(t) end 72 | 73 | ---@generic T 74 | ---@param name string 75 | ---@param def? T 76 | ---@return string|T 77 | ---@overload fun(name: string): string|nil 78 | function mp.get_property(name, def) end 79 | 80 | ---@generic T 81 | ---@param name string 82 | ---@param def? T 83 | ---@return boolean|T 84 | ---@overload fun(name: string): boolean|nil 85 | function mp.get_property_bool(name, def) end 86 | 87 | ---@generic T 88 | ---@param name string 89 | ---@param def? T 90 | ---@return number|T 91 | ---@overload fun(name: string): number|nil 92 | function mp.get_property_number(name, def) end 93 | 94 | ---@generic T 95 | ---@param name string 96 | ---@param def? T 97 | ---@return unknown|T 98 | ---@overload fun(name: string): unknown|nil 99 | function mp.get_property_native(name, def) end 100 | 101 | ---@return string|nil 102 | function mp.get_script_directory() end 103 | 104 | ---@return string 105 | function mp.get_script_name() end 106 | 107 | ---@param name string 108 | ---@param type 'native'|'bool'|'string'|'number' 109 | ---@param fn fun(name: string, v: unknown) 110 | function mp.observe_property(name, type, fn) end 111 | 112 | ---@param name string 113 | ---@param fn function 114 | ---@return boolean 115 | function mp.register_event(name, fn) end 116 | 117 | ---@param name string 118 | ---@param fn fun(...: string) 119 | function mp.register_script_message(name, fn) end 120 | 121 | ---@param name string 122 | function mp.remove_key_binding(name) end 123 | 124 | ---@param name string 125 | ---@param value string 126 | ---@return true? success # nil if error 127 | ---@return string? err 128 | function mp.set_property(name, value) end 129 | 130 | ---@param name string 131 | ---@param value boolean 132 | ---@return true? success # nil if error 133 | ---@return string? err 134 | function mp.set_property_bool(name, value) end 135 | 136 | ---@param name string 137 | ---@param value number 138 | ---@return true? success # nil if error 139 | ---@return string? err 140 | function mp.set_property_number(name, value) end 141 | 142 | ---@param name string 143 | ---@param value any 144 | ---@return true? success # nil if error 145 | ---@return string? err 146 | function mp.set_property_native(name, value) end 147 | 148 | return mp -------------------------------------------------------------------------------- /modules/defs/mp/input.lua: -------------------------------------------------------------------------------- 1 | ---@meta mp.input 2 | 3 | ---@class mp.input 4 | local input = {} 5 | 6 | ---@class InputGetOpts 7 | ---@field prompt string? 8 | ---@field default_text string? 9 | ---@field id string? 10 | ---@field submit (fun(text: string))? 11 | ---@field opened (fun())? 12 | ---@field edited (fun(text: string))? 13 | ---@field complete (fun(text_before_cursor: string): string[], number)? 14 | ---@field closed (fun(text: string))? 15 | 16 | ---@param options InputGetOpts 17 | function input.get(options) end 18 | 19 | function input.terminate() end 20 | 21 | return input -------------------------------------------------------------------------------- /modules/defs/mp/msg.lua: -------------------------------------------------------------------------------- 1 | ---@meta mp.msg 2 | 3 | ---@class mp.msg 4 | local msg = {} 5 | 6 | ---@param level 'fatal'|'error'|'warn'|'info'|'v'|'debug'|'trace' 7 | ---@param ... any 8 | function msg.log(level, ...) end 9 | 10 | ---@param ... any 11 | function msg.fatal(...) end 12 | 13 | ---@param ... any 14 | function msg.error(...) end 15 | 16 | ---@param ... any 17 | function msg.warn(...) end 18 | 19 | ---@param ... any 20 | function msg.info(...) end 21 | 22 | ---@param ... any 23 | function msg.verbose(...) end 24 | 25 | ---@param ... any 26 | function msg.debug(...) end 27 | 28 | ---@param ... any 29 | function msg.trace(...) end 30 | 31 | 32 | return msg -------------------------------------------------------------------------------- /modules/defs/mp/options.lua: -------------------------------------------------------------------------------- 1 | ---@meta mp.options 2 | 3 | ---@class mp.options 4 | local options = {} 5 | 6 | ---@param t table 7 | ---@param identifier? string 8 | ---@param on_update? fun(list: table) 9 | function options.read_options(t, identifier, on_update) end 10 | 11 | return options 12 | -------------------------------------------------------------------------------- /modules/defs/mp/utils.lua: -------------------------------------------------------------------------------- 1 | ---@meta mp.utils 2 | 3 | ---@class mp.utils 4 | local utils = {} 5 | 6 | ---@param v string|boolean|number|table|nil 7 | ---@return string? json # nil on error 8 | ---@return string? err # error 9 | function utils.format_json(v) end 10 | 11 | ---@param p1 string 12 | ---@param p2 string 13 | ---@return string 14 | function utils.join_path(p1, p2) end 15 | 16 | ---@param str string 17 | ---@param trail? boolean 18 | ---@return (table|unknown[])? t 19 | ---@return string? err # error 20 | ---@return string trail # trailing characters 21 | function utils.parse_json(str, trail) end 22 | 23 | ---@param path string 24 | ---@param filter ('files'|'dirs'|'normal'|'all')? 25 | ---@return string[]? # nil on error 26 | ---@return string? err # error 27 | function utils.readdir(path, filter) end 28 | 29 | ---@deprecated 30 | ---@param name string 31 | ---@param value string 32 | function utils.shared_script_property_set(name, value) end 33 | 34 | ---@param path string 35 | ---@return string directory 36 | ---@return string filename 37 | function utils.split_path(path) end 38 | 39 | ---@param v any 40 | ---@return string 41 | function utils.to_string(v) end 42 | 43 | return utils 44 | -------------------------------------------------------------------------------- /modules/defs/parser.lua: -------------------------------------------------------------------------------- 1 | ---@meta _ 2 | 3 | ---A ParserConfig object returned by addons 4 | ---@class (partial) ParserConfig: ParserAPI 5 | ---@field priority number? 6 | ---@field api_version string The minimum API version the string requires. 7 | ---@field version string? The minimum API version the string requires. @deprecated. 8 | --- 9 | ---@field can_parse (async fun(self: Parser, directory: string, parse_state: ParseState): boolean)? 10 | ---@field parse (async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?)? 11 | ---@field setup fun(self: Parser)? 12 | --- 13 | ---@field name string? 14 | ---@field keybind_name string? 15 | ---@field keybinds KeybindList? 16 | 17 | 18 | ---The parser object used by file-browser once the parsers have been loaded and initialised. 19 | ---@class Parser: ParserAPI, ParserConfig 20 | ---@field name string 21 | ---@field priority number 22 | ---@field api_version string 23 | ---@field can_parse async fun(self: Parser, directory: string, parse_state: ParseState): boolean 24 | ---@field parse async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts? 25 | 26 | 27 | ---@alias ParseStateSource 'browser'|'loadlist'|'script-message'|'addon'|string 28 | ---@alias ParseProperties table 29 | 30 | ---The Parse State object passed to the can_parse and parse methods 31 | ---@class ParseStateFields 32 | ---@field source ParseStateSource 33 | ---@field directory string 34 | ---@field already_deferred boolean? 35 | ---@field properties ParseProperties 36 | 37 | ---@class ParseState: ParseStateFields, ParseStateAPI 38 | 39 | ---@class ParseStateTemplate 40 | ---@field source ParseStateSource? 41 | ---@field properties ParseProperties? 42 | -------------------------------------------------------------------------------- /modules/defs/state.lua: -------------------------------------------------------------------------------- 1 | ---@meta _ 2 | 3 | ---@class Set: {[T]: boolean} 4 | 5 | ---@class (exact) State 6 | ---@field list List 7 | ---@field selected number 8 | ---@field hidden boolean 9 | ---@field flag_update boolean 10 | ---@field keybinds KeybindTupleStrict[]? 11 | --- 12 | ---@field parser Parser? 13 | ---@field directory string? 14 | ---@field directory_label string? 15 | ---@field prev_directory string 16 | ---@field empty_text string 17 | ---@field co thread? 18 | --- 19 | ---@field multiselect_start number? 20 | ---@field initial_selection Set? 21 | ---@field selection Set? -------------------------------------------------------------------------------- /modules/defs/user-input.lua: -------------------------------------------------------------------------------- 1 | ---@meta user-input-module 2 | 3 | ---@class user_input_module 4 | local user_input_module = {} 5 | 6 | ---@class UserInputOpts 7 | ---@field id string? 8 | ---@field source string? 9 | ---@field request_text string? 10 | ---@field default_input string? 11 | ---@field cursor_pos number? 12 | ---@field queueable boolean? 13 | ---@field replace boolean? 14 | 15 | ---@class UserInputRequest 16 | ---@field callback function? 17 | ---@field passthrough_args any[]? 18 | ---@field pending boolean 19 | ---@field cancel fun(self: UserInputRequest) 20 | ---@field update fun(self: UserInputRequest, opts: UserInputOpts) 21 | 22 | ---@param fn function 23 | ---@param opts UserInputOpts 24 | ---@param ... any passthrough arguments 25 | ---@return UserInputRequest 26 | function user_input_module.get_user_input(fn, opts, ...) end 27 | 28 | return user_input_module 29 | -------------------------------------------------------------------------------- /modules/globals.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------------------------------- 2 | ------------------------------------------Variable Setup------------------------------------------------ 3 | -------------------------------------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------------------------------- 5 | 6 | local mp = require 'mp' 7 | local o = require 'modules.options' 8 | 9 | ---@class globals 10 | local globals = {} 11 | 12 | --sets the version for the file-browser API 13 | globals.API_VERSION = "1.9.0" 14 | 15 | ---gets the current platform (in mpv v0.36+) 16 | ---in earlier versions it is set to `windows`, `darwin` or `other` 17 | ---@type 'windows'|'darwin'|'linux'|'android'|'freebsd'|'other'|string|nil 18 | globals.PLATFORM = mp.get_property_native('platform') 19 | if not globals.PLATFORM then 20 | local _ = {} 21 | if mp.get_property_native('options/vo-mmcss-profile', _) ~= _ then 22 | globals.PLATFORM = 'windows' 23 | elseif mp.get_property_native('options/macos-force-dedicated-gpu', _) ~= _ then 24 | globals.PLATFORM = 'darwin' 25 | end 26 | return 'other' 27 | end 28 | 29 | --the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30 30 | assert(mp.create_osd_overlay, "Script requires minimum mpv version 0.33") 31 | 32 | globals.ass = mp.create_osd_overlay("ass-events") 33 | globals.ass.res_y = 720 / o.scaling_factor_base 34 | 35 | local BASE_FONT_SIZE = 25 36 | 37 | --force file-browser to use a specific text alignment (default: top-left) 38 | --uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3 39 | globals.ASS_ALIGNMENT_MATRIX = { 40 | top = {left = 7, center = 8, right = 9}, 41 | center = {left = 4, center = 5, right = 6}, 42 | bottom = {left = 1, center = 2, right = 3}, 43 | } 44 | 45 | globals.ALIGN_X = o.align_x == 'auto' and mp.get_property('osd-align-x', 'left') or o.align_x 46 | globals.ALIGN_Y = o.align_y == 'auto' and mp.get_property('osd-align-y', 'top') or o.align_y 47 | 48 | globals.style = { 49 | global = ([[{\an%d}]]):format(globals.ASS_ALIGNMENT_MATRIX[globals.ALIGN_Y][globals.ALIGN_X]), 50 | 51 | -- full line styles 52 | header = ([[{\r\q2\b%s\fs%d\fn%s\c&H%s&}]]):format((o.font_bold_header and "1" or "0"), o.scaling_factor_header*BASE_FONT_SIZE, o.font_name_header, o.font_colour_header), 53 | body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(BASE_FONT_SIZE, o.font_name_body, o.font_colour_body), 54 | footer_header = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.scaling_factor_wrappers*BASE_FONT_SIZE, o.font_name_wrappers, o.font_colour_wrappers), 55 | 56 | --small section styles (for colours) 57 | multiselect = ([[{\c&H%s&}]]):format(o.font_colour_multiselect), 58 | selected = ([[{\c&H%s&}]]):format(o.font_colour_selected), 59 | playing = ([[{\c&H%s&}]]):format(o.font_colour_playing), 60 | playing_selected = ([[{\c&H%s&}]]):format(o.font_colour_playing_multiselected), 61 | warning = ([[{\c&H%s&}]]):format(o.font_colour_escape_chars), 62 | 63 | --icon styles 64 | indent = ([[{\alpha&H%s}]]):format('ff'), 65 | cursor = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_cursor), 66 | cursor_select = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_multiselect), 67 | cursor_deselect = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_selected), 68 | folder = ([[{\fn%s}]]):format(o.font_name_folder), 69 | selection_marker = ([[{\alpha&H%s}]]):format(o.font_opacity_selection_marker), 70 | } 71 | 72 | ---@type State 73 | globals.state = { 74 | list = {}, 75 | selected = 1, 76 | hidden = true, 77 | flag_update = false, 78 | keybinds = nil, 79 | 80 | parser = nil, 81 | directory = nil, 82 | directory_label = nil, 83 | prev_directory = '', 84 | empty_text = 'Empty Directory', 85 | co = nil, 86 | 87 | multiselect_start = nil, 88 | initial_selection = nil, 89 | selection = {} 90 | } 91 | 92 | ---@class ParserRef 93 | ---@field id string 94 | ---@field index number? 95 | 96 | ---@type table|table|table> 97 | --the parser table actually contains 3 entries for each parser 98 | --a numeric entry which represents the priority of the parsers and has the parser object as the value 99 | --a string entry representing the id of each parser and with the parser object as the value 100 | --and a table entry with the parser itself as the key and a table value in the form { id = %s, index = %d } 101 | globals.parsers = {} 102 | 103 | --this table contains the parse_state tables for every parse operation indexed with the coroutine used for the parse 104 | --this table has weakly referenced keys, meaning that once the coroutine for a parse is no-longer used by anything that 105 | --field in the table will be removed by the garbage collector 106 | ---@type table 107 | globals.parse_states = setmetatable({}, { __mode = "k"}) 108 | 109 | ---@type Set 110 | globals.extensions = {} 111 | 112 | ---@type Set 113 | globals.sub_extensions = {} 114 | 115 | ---@type Set 116 | globals.audio_extensions = {} 117 | 118 | ---@type Set 119 | globals.parseable_extensions = {} 120 | 121 | ---This table contains mappings to convert external directories to cannonical 122 | --locations within the file-browser file tree. The keys of the table are Lua 123 | --patterns used to evaluate external directory paths. The value is the path 124 | --that should replace the part of the path than matched the pattern. 125 | --These mappings should only applied at the edges where external paths are 126 | --ingested by file-browser. 127 | ---@type table 128 | globals.directory_mappings = {} 129 | 130 | ---@class CurrentFile 131 | ---@field directory string? 132 | ---@field name string? 133 | ---@field path string? 134 | ---@field original_path string? 135 | globals.current_file = { 136 | directory = nil, 137 | name = nil, 138 | path = nil, 139 | original_path = nil, 140 | } 141 | 142 | ---@type List 143 | globals.root = {} 144 | 145 | ---@class (strict) History 146 | ---@field list string[] 147 | ---@field size number 148 | ---@field position number 149 | globals.history = { 150 | list = {}, 151 | size = 0, 152 | position = 0, 153 | } 154 | 155 | ---@class (strict) DirectoryStack 156 | ---@field stack string[] 157 | ---@field position number 158 | globals.directory_stack = { 159 | stack = {}, 160 | position = 0, 161 | } 162 | 163 | 164 | --default list of compatible file extensions 165 | --adding an item to this list is a valid request on github 166 | globals.compatible_file_extensions = { 167 | "264","265","3g2","3ga","3ga2","3gp","3gp2","3gpp","3iv","a52","aac","adt","adts","ahn","aif","aifc","aiff","amr","ape","asf","au","avc","avi","awb","ay", 168 | "bmp","cue","divx","dts","dtshd","dts-hd","dv","dvr","dvr-ms","eac3","evo","evob","f4a","flac","flc","fli","flic","flv","gbs","gif","gxf","gym", 169 | "h264","h265","hdmov","hdv","hes","hevc","jpeg","jpg","kss","lpcm","m1a","m1v","m2a","m2t","m2ts","m2v","m3u","m3u8","m4a","m4v","mk3d","mka","mkv", 170 | "mlp","mod","mov","mp1","mp2","mp2v","mp3","mp4","mp4v","mp4v","mpa","mpe","mpeg","mpeg2","mpeg4","mpg","mpg4","mpv","mpv2","mts","mtv","mxf","nsf", 171 | "nsfe","nsv","nut","oga","ogg","ogm","ogv","ogx","opus","pcm","pls","png","qt","ra","ram","rm","rmvb","sap","snd","spc","spx","svg","thd","thd+ac3", 172 | "tif","tiff","tod","trp","truehd","true-hd","ts","tsa","tsv","tta","tts","vfw","vgm","vgz","vob","vro","wav","weba","webm","webp","wm","wma","wmv","wtv", 173 | "wv","x264","x265","xvid","y4m","yuv" 174 | } 175 | 176 | ---@class BrowserAbortError 177 | globals.ABORT_ERROR = { 178 | msg = "browser is no longer waiting for list - aborting parse" 179 | } 180 | 181 | return globals 182 | -------------------------------------------------------------------------------- /modules/keybinds.lua: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------------------------------------ 2 | ----------------------------------Keybind Implementation---------------------------------- 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 addons = require 'modules.addons' 14 | local playlist = require 'modules.playlist' 15 | local controls = require 'modules.controls' 16 | local movement = require 'modules.navigation.directory-movement' 17 | local scanning = require 'modules.navigation.scanning' 18 | local cursor = require 'modules.navigation.cursor' 19 | 20 | g.state.keybinds = { 21 | {'ENTER', 'play', function() playlist.add_files('replace', false) end}, 22 | {'Shift+ENTER', 'play_append', function() playlist.add_files('append-play', false) end}, 23 | {'Alt+ENTER', 'play_autoload', function() playlist.add_files('replace', true) end}, 24 | {'ESC', 'close', controls.escape}, 25 | {'RIGHT', 'down_dir', movement.down_dir}, 26 | {'LEFT', 'up_dir', movement.up_dir}, 27 | {'Alt+RIGHT', 'history_forward', movement.forwards_history}, 28 | {'Alt+LEFT', 'history_back', movement.back_history}, 29 | {'DOWN', 'scroll_down', function() cursor.scroll(1, o.wrap) end, {repeatable = true}}, 30 | {'UP', 'scroll_up', function() cursor.scroll(-1, o.wrap) end, {repeatable = true}}, 31 | {'PGDWN', 'page_down', function() cursor.scroll(o.num_entries) end, {repeatable = true}}, 32 | {'PGUP', 'page_up', function() cursor.scroll(-o.num_entries) end, {repeatable = true}}, 33 | {'Shift+PGDWN', 'list_bottom', function() cursor.scroll(math.huge) end}, 34 | {'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end}, 35 | {'HOME', 'goto_current', movement.goto_current_dir}, 36 | {'Shift+HOME', 'goto_root', movement.goto_root}, 37 | {'Ctrl+r', 'reload', function() scanning.rescan() end}, 38 | {'s', 'select_mode', cursor.toggle_select_mode}, 39 | {'S', 'select_item', cursor.toggle_selection}, 40 | {'Ctrl+a', 'select_all', cursor.select_all} 41 | } 42 | 43 | ---a map of key-keybinds - only saves the latest keybind if multiple have the same key code 44 | ---@type KeybindList 45 | local top_level_keys = {} 46 | 47 | ---Format the item string for either single or multiple items. 48 | ---@param base_code_fn Replacer 49 | ---@param items Item[] 50 | ---@param state State 51 | ---@param cmd Keybind 52 | ---@param quoted? boolean 53 | ---@return string|nil 54 | local function create_item_string(base_code_fn, items, state, cmd, quoted) 55 | if not items[1] then return end 56 | local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn 57 | 58 | local out = {} 59 | for _, item in ipairs(items) do 60 | table.insert(out, func(item, state)) 61 | end 62 | 63 | return table.concat(out, cmd['concat-string'] or ' ') 64 | end 65 | 66 | local KEYBIND_CODE_PATTERN = fb_utils.get_code_pattern(fb_utils.code_fns) 67 | local item_specific_codes = 'fnij' 68 | 69 | ---Replaces codes in the given string using the replacers. 70 | ---@param str string 71 | ---@param cmd Keybind 72 | ---@param items Item[] 73 | ---@param state State 74 | ---@return string 75 | local function substitute_codes(str, cmd, items, state) 76 | ---@type ReplacerTable 77 | local overrides = {} 78 | 79 | for code in item_specific_codes:gmatch('.') do 80 | overrides[code] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd) end 81 | overrides[code:upper()] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd, true) end 82 | end 83 | 84 | return fb_utils.substitute_codes(str, overrides, items[1], state) 85 | end 86 | 87 | ---Iterates through the command table and substitutes special 88 | ---character codes for the correct strings used for custom functions. 89 | ---@param cmd Keybind 90 | ---@param items Item[] 91 | ---@param state State 92 | ---@return KeybindCommand 93 | local function format_command_table(cmd, items, state) 94 | local command = cmd.command 95 | if type(command) == 'function' then return command end 96 | ---@type string[][] 97 | local copy = {} 98 | for i = 1, #command do 99 | ---@type string[] 100 | copy[i] = {} 101 | 102 | for j = 1, #command[i] do 103 | copy[i][j] = substitute_codes(cmd.command[i][j], cmd, items, state) 104 | end 105 | end 106 | return copy 107 | end 108 | 109 | ---Runs all of the commands in the command table. 110 | ---@param cmd Keybind key.command must be an array of command tables compatible with mp.command_native 111 | ---@param items Item[] must be an array of multiple items (when multi-type ~= concat the array will be 1 long). 112 | ---@param state State 113 | local function run_custom_command(cmd, items, state) 114 | local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command 115 | if type(custom_cmds) == 'function' then 116 | error(('attempting to run a function keybind as a command table keybind\n%s'):format(utils.to_string(cmd))) 117 | end 118 | 119 | for _, custom_cmd in ipairs(custom_cmds) do 120 | msg.debug("running command:", utils.to_string(custom_cmd)) 121 | mp.command_native(custom_cmd) 122 | end 123 | end 124 | 125 | ---returns true if the given code set has item specific codes (%f, %i, etc) 126 | ---@param codes Set 127 | ---@return boolean 128 | local function has_item_codes(codes) 129 | for code in pairs(codes) do 130 | if item_specific_codes:find(code:lower(), 1, true) then return true end 131 | end 132 | return false 133 | end 134 | 135 | ---Runs one of the custom commands. 136 | ---@async 137 | ---@param cmd Keybind 138 | ---@param state State 139 | ---@param co thread 140 | ---@return boolean|nil 141 | local function run_custom_keybind(cmd, state, co) 142 | --evaluates a condition and passes through the correct values 143 | local function evaluate_condition(condition, items) 144 | local cond = substitute_codes(condition, cmd, items, state) 145 | return fb_utils.evaluate_string('return '..cond) == true 146 | end 147 | 148 | -- evaluates the string condition to decide if the keybind should be run 149 | ---@type boolean 150 | local do_item_condition 151 | if cmd.condition then 152 | if has_item_codes(cmd.condition_codes) then 153 | do_item_condition = true 154 | elseif not evaluate_condition(cmd.condition, {}) then 155 | return false 156 | end 157 | end 158 | 159 | if cmd.parser then 160 | local parser_str = ' '..cmd.parser..' ' 161 | if not parser_str:find( '%W'..(state.parser.keybind_name or state.parser.name)..'%W' ) then return false end 162 | end 163 | 164 | --these are for the default keybinds, or from addons which use direct functions 165 | if type(cmd.command) == 'function' then return cmd.command(cmd, cmd.addon and fb_utils.copy_table(state) or state, co) end 166 | 167 | --the function terminates here if we are running the command on a single item 168 | if not (cmd.multiselect and next(state.selection)) then 169 | if cmd.filter then 170 | if not state.list[state.selected] then return false end 171 | if state.list[state.selected].type ~= cmd.filter then return false end 172 | end 173 | 174 | if cmd.codes then 175 | --if the directory is empty, and this command needs to work on an item, then abort and fallback to the next command 176 | if not state.list[state.selected] and has_item_codes(cmd.codes) then return false end 177 | end 178 | 179 | if do_item_condition and not evaluate_condition(cmd.condition, { state.list[state.selected] }) then 180 | return false 181 | end 182 | run_custom_command(cmd, { state.list[state.selected] }, state) 183 | return true 184 | end 185 | 186 | --runs the command on all multi-selected items 187 | local selection = fb_utils.sort_keys(state.selection, function(item) 188 | if do_item_condition and not evaluate_condition(cmd.condition, { item }) then return false end 189 | return not cmd.filter or item.type == cmd.filter 190 | end) 191 | if not next(selection) then return false end 192 | 193 | if cmd["multi-type"] == "concat" then 194 | run_custom_command(cmd, selection, state) 195 | 196 | elseif cmd["multi-type"] == "repeat" or cmd["multi-type"] == nil then 197 | for i,_ in ipairs(selection) do 198 | run_custom_command(cmd, {selection[i]}, state) 199 | 200 | if cmd.delay then 201 | mp.add_timeout(cmd.delay, function() fb_utils.coroutine.resume_err(co) end) 202 | coroutine.yield() 203 | end 204 | end 205 | end 206 | 207 | --we passthrough by default if the command is not run on every selected item 208 | if cmd.passthrough ~= nil then return end 209 | 210 | local num_selection = 0 211 | for _ in pairs(state.selection) do num_selection = num_selection+1 end 212 | return #selection == num_selection 213 | end 214 | 215 | ---Recursively runs the keybind functions, passing down through the chain 216 | ---of keybinds with the same key value. 217 | ---@async 218 | ---@param keybind Keybind 219 | ---@param state State 220 | ---@param co thread 221 | local function run_keybind_recursive(keybind, state, co) 222 | msg.trace("Attempting custom command:", utils.to_string(keybind)) 223 | 224 | if keybind.passthrough ~= nil then 225 | run_custom_keybind(keybind, state, co) 226 | if keybind.passthrough == true and keybind.prev_key then 227 | run_keybind_recursive(keybind.prev_key, state, co) 228 | end 229 | else 230 | if run_custom_keybind(keybind, state, co) == false and keybind.prev_key then 231 | run_keybind_recursive(keybind.prev_key, state, co) 232 | end 233 | end 234 | end 235 | 236 | ---A wrapper to run a custom keybind as a lua coroutine. 237 | ---@param key Keybind 238 | local function run_keybind_coroutine(key) 239 | msg.debug("Received custom keybind "..key.key) 240 | local co = coroutine.create(run_keybind_recursive) 241 | 242 | local state_copy = { 243 | directory = g.state.directory, 244 | directory_label = g.state.directory_label, 245 | list = g.state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables 246 | selected = g.state.selected, 247 | selection = fb_utils.copy_table(g.state.selection), 248 | parser = g.state.parser, 249 | } 250 | local success, err = coroutine.resume(co, key, state_copy, co) 251 | if not success then 252 | msg.error("error running keybind:", utils.to_string(key)) 253 | fb_utils.traceback(err, co) 254 | end 255 | end 256 | 257 | ---Scans the given command table to identify if they contain any custom keybind codes. 258 | ---@param command_table KeybindCommand 259 | ---@param codes Set 260 | ---@return Set 261 | local function scan_for_codes(command_table, codes) 262 | if type(command_table) ~= "table" then return codes end 263 | for _, value in pairs(command_table) do 264 | local type = type(value) 265 | if type == "table" then 266 | scan_for_codes(value, codes) 267 | elseif type == "string" then 268 | for code in value:gmatch(KEYBIND_CODE_PATTERN) do 269 | codes[code] = true 270 | end 271 | end 272 | end 273 | return codes 274 | end 275 | 276 | ---Inserting the custom keybind into the keybind array for declaration when file-browser is opened. 277 | ---Custom keybinds with matching names will overwrite eachother. 278 | ---@param keybind Keybind 279 | local function insert_custom_keybind(keybind) 280 | -- api checking for the keybinds is optional, so set to a valid version if it does not exist 281 | keybind.api_version = keybind.api_version or '1.0.0' 282 | if not addons.check_api_version(keybind, 'keybind '..keybind.name) then return end 283 | 284 | local command = keybind.command 285 | 286 | --we'll always save the keybinds as either an array of command arrays or a function 287 | if type(command) == "table" and type(command[1]) ~= "table" then 288 | keybind.command = {command} 289 | end 290 | 291 | keybind.codes = scan_for_codes(keybind.command, {}) 292 | if not next(keybind.codes) then keybind.codes = nil end 293 | keybind.prev_key = top_level_keys[keybind.key] 294 | 295 | if keybind.condition then 296 | keybind.condition_codes = {} 297 | for code in string.gmatch(keybind.condition, KEYBIND_CODE_PATTERN) do keybind.condition_codes[code] = true end 298 | end 299 | 300 | table.insert(g.state.keybinds, {keybind.key, keybind.name, function() run_keybind_coroutine(keybind) end, keybind.flags or {}}) 301 | top_level_keys[keybind.key] = keybind 302 | end 303 | 304 | ---Loading the custom keybinds. 305 | ---Can either load keybinds from the config file, from addons, or from both. 306 | local function setup_keybinds() 307 | --this is to make the default keybinds compatible with passthrough from custom keybinds 308 | for _, keybind in ipairs(g.state.keybinds) do 309 | top_level_keys[keybind[1]] = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] } 310 | end 311 | 312 | --this loads keybinds from addons 313 | for i = #g.parsers, 1, -1 do 314 | local parser = g.parsers[i] 315 | if parser.keybinds then 316 | for i, keybind in ipairs(parser.keybinds) do 317 | --if addons use the native array command format, then we need to convert them over to the custom command format 318 | if not keybind.key then keybind = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] } 319 | else keybind = fb_utils.copy_table(keybind) end 320 | 321 | keybind.name = g.parsers[parser].id.."/"..(keybind.name or tostring(i)) 322 | keybind.addon = true 323 | insert_custom_keybind(keybind) 324 | end 325 | end 326 | end 327 | 328 | --loads custom keybinds from file-browser-keybinds.json 329 | if o.custom_keybinds then 330 | local path = mp.command_native({"expand-path", o.custom_keybinds_file}) --[[@as string]] 331 | local custom_keybinds, err = io.open( path ) 332 | if not custom_keybinds then 333 | msg.debug(err) 334 | msg.verbose('could not read custom keybind file', path) 335 | return 336 | end 337 | 338 | local json = custom_keybinds:read("*a") 339 | custom_keybinds:close() 340 | 341 | json = utils.parse_json(json) 342 | if not json then return error("invalid json syntax for "..path) end 343 | 344 | for i, keybind in ipairs(json --[[@as KeybindList]]) do 345 | keybind.name = "custom/"..(keybind.name or tostring(i)) 346 | insert_custom_keybind(keybind) 347 | end 348 | end 349 | end 350 | 351 | ---@class keybinds 352 | return { 353 | setup_keybinds = setup_keybinds, 354 | } 355 | -------------------------------------------------------------------------------- /modules/navigation/cursor.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------------------------------- 2 | --------------------------------Scroll/Select Implementation-------------------------------------------- 3 | -------------------------------------------------------------------------------------------------------- 4 | -------------------------------------------------------------------------------------------------------- 5 | 6 | local g = require 'modules.globals' 7 | local fb_utils = require 'modules.utils' 8 | local ass = require 'modules.ass' 9 | 10 | ---@class cursor 11 | local cursor = {} 12 | 13 | --disables multiselect 14 | function cursor.disable_select_mode() 15 | g.state.multiselect_start = nil 16 | g.state.initial_selection = nil 17 | end 18 | 19 | --enables multiselect 20 | function cursor.enable_select_mode() 21 | g.state.multiselect_start = g.state.selected 22 | g.state.initial_selection = fb_utils.copy_table(g.state.selection) 23 | end 24 | 25 | --calculates what drag behaviour is required for that specific movement 26 | local function drag_select(original_pos, new_pos) 27 | if original_pos == new_pos then return end 28 | 29 | local setting = g.state.selection[g.state.multiselect_start or -1] 30 | for i = original_pos, new_pos, (new_pos > original_pos and 1 or -1) do 31 | --if we're moving the cursor away from the starting point then set the selection 32 | --otherwise restore the original selection 33 | if i > g.state.multiselect_start then 34 | if new_pos > original_pos then 35 | g.state.selection[i] = setting 36 | elseif i ~= new_pos then 37 | g.state.selection[i] = g.state.initial_selection[i] 38 | end 39 | elseif i < g.state.multiselect_start then 40 | if new_pos < original_pos then 41 | g.state.selection[i] = setting 42 | elseif i ~= new_pos then 43 | g.state.selection[i] = g.state.initial_selection[i] 44 | end 45 | end 46 | end 47 | end 48 | 49 | --moves the selector up and down the list by the entered amount 50 | function cursor.scroll(n, wrap) 51 | local num_items = #g.state.list 52 | if num_items == 0 then return end 53 | 54 | local original_pos = g.state.selected 55 | 56 | if original_pos + n > num_items then 57 | g.state.selected = wrap and 1 or num_items 58 | elseif original_pos + n < 1 then 59 | g.state.selected = wrap and num_items or 1 60 | else 61 | g.state.selected = original_pos + n 62 | end 63 | 64 | if g.state.multiselect_start then drag_select(original_pos, g.state.selected) end 65 | ass.update_ass() 66 | end 67 | 68 | --selects the first item in the list which is highlighted as playing 69 | function cursor.select_playing_item() 70 | for i,item in ipairs(g.state.list) do 71 | if ass.highlight_entry(item) then 72 | g.state.selected = i 73 | return 74 | end 75 | end 76 | end 77 | 78 | --scans the list for which item to select by default 79 | --chooses the folder that the script just moved out of 80 | --or, otherwise, the item highlighted as currently playing 81 | function cursor.select_prev_directory() 82 | -- makes use of the directory stack to more exactly select the prev directory 83 | local down_stack = g.directory_stack.stack[g.directory_stack.position + 1] 84 | if down_stack then 85 | for i, item in ipairs(g.state.list) do 86 | if fb_utils.get_new_directory(item, g.state.directory) == down_stack then 87 | g.state.selected = i 88 | return 89 | end 90 | end 91 | end 92 | 93 | if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then 94 | for i, item in ipairs(g.state.list) do 95 | if 96 | g.state.prev_directory:find(fb_utils.get_full_path(item), 1, true) or 97 | g.state.prev_directory:find(fb_utils.get_new_directory(item, g.state.directory), 1, true) 98 | then 99 | g.state.selected = i 100 | return 101 | end 102 | end 103 | end 104 | 105 | cursor.select_playing_item() 106 | end 107 | 108 | --toggles the selection 109 | function cursor.toggle_selection() 110 | if not g.state.list[g.state.selected] then return end 111 | g.state.selection[g.state.selected] = not g.state.selection[g.state.selected] or nil 112 | ass.update_ass() 113 | end 114 | 115 | --select all items in the list 116 | function cursor.select_all() 117 | for i,_ in ipairs(g.state.list) do 118 | g.state.selection[i] = true 119 | end 120 | ass.update_ass() 121 | end 122 | 123 | --toggles select mode 124 | function cursor.toggle_select_mode() 125 | if g.state.multiselect_start == nil then 126 | cursor.enable_select_mode() 127 | cursor.toggle_selection() 128 | else 129 | cursor.disable_select_mode() 130 | ass.update_ass() 131 | end 132 | end 133 | 134 | return cursor 135 | -------------------------------------------------------------------------------- /modules/navigation/directory-movement.lua: -------------------------------------------------------------------------------- 1 | 2 | local mp = require 'mp' 3 | local msg = require 'mp.msg' 4 | local utils = require 'mp.utils' 5 | 6 | local o = require 'modules.options' 7 | local g = require 'modules.globals' 8 | local ass = require 'modules.ass' 9 | local scanning = require 'modules.navigation.scanning' 10 | local fb_utils = require 'modules.utils' 11 | local cursor = require 'modules.navigation.cursor' 12 | 13 | ---@class directory_movement 14 | local directory_movement = {} 15 | local NavType = scanning.NavType 16 | 17 | ---Appends an item to the directory stack, wiping any 18 | ---directories further ahead than the current position. 19 | ---@param dir string 20 | local function directory_stack_append(dir) 21 | -- don't clear the stack if we're re-entering the same directory 22 | if g.directory_stack.stack[g.directory_stack.position + 1] == dir then 23 | g.directory_stack.position = g.directory_stack.position + 1 24 | return 25 | end 26 | 27 | local j = #g.directory_stack.stack 28 | while g.directory_stack.position < j do 29 | g.directory_stack.stack[j] = nil 30 | j = j - 1 31 | end 32 | table.insert(g.directory_stack.stack, dir) 33 | g.directory_stack.position = g.directory_stack.position + 1 34 | end 35 | 36 | ---@param dir string 37 | local function directory_stack_prepend(dir) 38 | table.insert(g.directory_stack.stack, 1, dir) 39 | g.directory_stack.position = 1 40 | end 41 | 42 | ---Clears directories from the history 43 | ---@param from? number All entries >= this index are cleared. 44 | ---@return string[] 45 | function directory_movement.clear_history(from) 46 | ---@type string[] 47 | local cleared = {} 48 | 49 | from = from or 1 50 | for i = g.history.size, from, -1 do 51 | table.insert(cleared, g.history.list[i]) 52 | g.history.list[i] = nil 53 | g.history.size = g.history.size - 1 54 | 55 | if g.history.position >= i then 56 | g.history.position = g.history.position - 1 57 | end 58 | end 59 | 60 | return cleared 61 | end 62 | 63 | ---Append a directory to the history 64 | ---If we have navigated backward in the history, 65 | ---then clear any history beyond the current point. 66 | ---@param directory string 67 | function directory_movement.append_history(directory) 68 | if g.history.list[g.history.position] == directory then 69 | msg.debug('reloading same directory - history unchanged:', directory) 70 | return 71 | end 72 | 73 | msg.debug('appending to history:', directory) 74 | if g.history.position < g.history.size then 75 | directory_movement.clear_history(g.history.position + 1) 76 | end 77 | 78 | table.insert(g.history.list, directory) 79 | g.history.size = g.history.size + 1 80 | g.history.position = g.history.position + 1 81 | 82 | if g.history.size > o.history_size then 83 | table.remove(g.history.list, 1) 84 | g.history.size = g.history.size - 1 85 | end 86 | end 87 | 88 | ---@param filepath string 89 | function directory_movement.set_current_file(filepath) 90 | --if we're in idle mode then we want to open the working directory 91 | if filepath == nil then 92 | g.current_file.directory = fb_utils.fix_path( mp.get_property("working-directory", ""), true) 93 | g.current_file.name = nil 94 | g.current_file.path = nil 95 | g.current_file.original_path = nil 96 | return 97 | end 98 | 99 | local absolute_path = fb_utils.absolute_path(filepath) 100 | local resolved_path = fb_utils.resolve_directory_mapping(absolute_path) 101 | 102 | g.current_file.directory, g.current_file.name = utils.split_path(resolved_path) 103 | g.current_file.original_path = absolute_path 104 | g.current_file.path = resolved_path 105 | 106 | if o.cursor_follows_playing_item then cursor.select_playing_item() end 107 | ass.update_ass() 108 | end 109 | 110 | --the base function for moving to a directory 111 | ---@param directory string 112 | ---@param nav_type? NavigationType 113 | ---@param store_history? boolean default `true` 114 | ---@param parse_properties? ParseProperties 115 | ---@return thread 116 | function directory_movement.goto_directory(directory, nav_type, store_history, parse_properties) 117 | local current = g.state.list[g.state.selected] 118 | g.state.directory = directory 119 | 120 | if g.state.directory_label then 121 | if nav_type == NavType.DOWN then 122 | g.state.directory_label = g.state.directory_label..(current.label or current.name) 123 | elseif nav_type == NavType.UP then 124 | g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$") 125 | end 126 | end 127 | 128 | if o.history_size > 0 and store_history == nil or store_history then 129 | directory_movement.append_history(directory) 130 | end 131 | 132 | return scanning.rescan(nav_type or NavType.GOTO, nil, parse_properties) 133 | end 134 | 135 | ---Move the browser to a particular point in the browser history. 136 | ---The history is a linear list of visited directories from oldest to newest. 137 | ---If the user changes directories while the current history position is not the head of the list, 138 | ---any later directories get cleared and the new directory becomes the new head. 139 | ---@param pos number The history index to move to. Clamped to [1,history_length] 140 | ---@return number|false # The index actually moved to after clamping. Returns -1 if the index was invalid (can occur if history is empty or disabled) 141 | function directory_movement.goto_history(pos) 142 | if type(pos) ~= "number" then return false end 143 | 144 | if pos < 1 then pos = 1 145 | elseif pos > g.history.size then pos = g.history.size end 146 | if not g.history.list[pos] then return false end 147 | 148 | g.history.position = pos 149 | directory_movement.goto_directory(g.history.list[pos]) 150 | return pos 151 | end 152 | 153 | --loads the root list 154 | function directory_movement.goto_root() 155 | msg.verbose('jumping to root') 156 | return directory_movement.goto_directory("") 157 | end 158 | 159 | --switches to the directory of the currently playing file 160 | function directory_movement.goto_current_dir() 161 | msg.verbose('jumping to current directory') 162 | return directory_movement.goto_directory(g.current_file.directory) 163 | end 164 | 165 | --moves up a directory 166 | function directory_movement.up_dir() 167 | if g.state.directory == '' then return end 168 | 169 | local cached_parent_dir = g.directory_stack.stack[g.directory_stack.position - 1] 170 | if cached_parent_dir then 171 | g.directory_stack.position = g.directory_stack.position - 1 172 | return directory_movement.goto_directory(cached_parent_dir, NavType.UP) 173 | end 174 | 175 | local parent_dir = g.state.directory:match("^(.-/+)[^/]+/*$") or "" 176 | 177 | if o.skip_protocol_schemes and parent_dir:find("^(%a[%w+-.]*)://$") then 178 | return directory_movement.goto_root() 179 | end 180 | 181 | directory_stack_prepend(parent_dir) 182 | return directory_movement.goto_directory(parent_dir, NavType.UP) 183 | end 184 | 185 | --moves down a directory 186 | function directory_movement.down_dir() 187 | local current = g.state.list[g.state.selected] 188 | if not current or not fb_utils.parseable_item(current) then return end 189 | 190 | local directory, redirected = fb_utils.get_new_directory(current, g.state.directory) 191 | directory_stack_append(directory) 192 | return directory_movement.goto_directory(directory, redirected and NavType.REDIRECT or NavType.DOWN) 193 | end 194 | 195 | --moves backwards through the directory history 196 | function directory_movement.back_history() 197 | msg.debug('moving backwards in history to', g.history.list[g.history.position-1]) 198 | if g.history.position == 1 then return end 199 | directory_movement.goto_history(g.history.position - 1) 200 | end 201 | 202 | --moves forward through the history 203 | function directory_movement.forwards_history() 204 | msg.debug('moving forwards in history to', g.history.list[g.history.position+1]) 205 | if g.history.position == g.history.size then return end 206 | directory_movement.goto_history(g.history.position + 1) 207 | end 208 | 209 | return directory_movement 210 | -------------------------------------------------------------------------------- /modules/navigation/scanning.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | local utils = require 'mp.utils' 4 | 5 | local g = require 'modules.globals' 6 | local fb_utils = require 'modules.utils' 7 | local cursor = require 'modules.navigation.cursor' 8 | local ass = require 'modules.ass' 9 | 10 | local parse_state_API = require 'modules.apis.parse-state' 11 | 12 | ---@class scanning 13 | local scanning = {} 14 | 15 | ---@enum NavigationType 16 | local NavType = { 17 | DOWN = 1, 18 | UP = -1, 19 | REDIRECT = 2, 20 | GOTO = 3, 21 | RESCAN = 4, 22 | } 23 | 24 | scanning.NavType = NavType 25 | 26 | ---@param directory_stack? boolean 27 | local function clear_non_adjacent_state(directory_stack) 28 | g.state.directory_label = nil 29 | if directory_stack then 30 | g.directory_stack.stack = {g.state.directory} 31 | g.directory_stack.position = 1 32 | end 33 | end 34 | 35 | ---parses the given directory or defers to the next parser if nil is returned 36 | ---@async 37 | ---@param directory string 38 | ---@param index number 39 | ---@return List? 40 | ---@return Opts? 41 | function scanning.choose_and_parse(directory, index) 42 | msg.debug(("finding parser for %q"):format(directory)) 43 | ---@type Parser, List?, Opts? 44 | local parser, list, opts 45 | local parse_state = g.parse_states[coroutine.running() or ""] 46 | while list == nil and not parse_state.already_deferred and index <= #g.parsers do 47 | parser = g.parsers[index] 48 | if parser:can_parse(directory, parse_state) then 49 | msg.debug("attempting parser:", parser:get_id()) 50 | list, opts = parser:parse(directory, parse_state) 51 | end 52 | index = index + 1 53 | end 54 | if not list then return nil, {} end 55 | 56 | msg.debug("list returned from:", parser:get_id()) 57 | opts = opts or {} 58 | if list then opts.id = opts.id or parser:get_id() end 59 | return list, opts 60 | end 61 | 62 | ---Sets up the parse_state table and runs the parse operation. 63 | ---@async 64 | ---@param directory string 65 | ---@param parse_state_template ParseStateTemplate 66 | ---@return List|nil 67 | ---@return Opts 68 | local function run_parse(directory, parse_state_template) 69 | msg.verbose(("scanning files in %q"):format(directory)) 70 | 71 | ---@type ParseStateFields 72 | local parse_state = { 73 | source = parse_state_template.source, 74 | directory = directory, 75 | properties = parse_state_template.properties or {} 76 | } 77 | 78 | local co = coroutine.running() 79 | g.parse_states[co] = fb_utils.set_prototype(parse_state, parse_state_API) --[[@as ParseState]] 80 | 81 | local list, opts = scanning.choose_and_parse(directory, 1) 82 | 83 | if list == nil then return msg.debug("no successful parsers found"), {} end 84 | opts = opts or {} 85 | opts.parser = g.parsers[opts.id] 86 | 87 | if not opts.filtered then fb_utils.filter(list) end 88 | if not opts.sorted then fb_utils.sort(list) end 89 | return list, opts 90 | end 91 | 92 | ---Returns the contents of the given directory using the given parse state. 93 | ---If a coroutine has already been used for a parse then create a new coroutine so that 94 | ---the every parse operation has a unique thread ID. 95 | ---@async 96 | ---@param directory string 97 | ---@param parse_state ParseStateTemplate 98 | ---@return List|nil 99 | ---@return Opts 100 | function scanning.scan_directory(directory, parse_state) 101 | local co = fb_utils.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state)) 102 | if not g.parse_states[co] then return run_parse(directory, parse_state) end 103 | 104 | --if this coroutine is already is use by another parse operation then we create a new 105 | --one and hand execution over to that 106 | ---@async 107 | local new_co = coroutine.create(function() 108 | fb_utils.coroutine.resume_err(co, run_parse(directory, parse_state)) 109 | end) 110 | 111 | --queue the new coroutine on the mpv event queue 112 | mp.add_timeout(0, function() 113 | local success, err = coroutine.resume(new_co) 114 | if not success then 115 | fb_utils.traceback(err, new_co) 116 | fb_utils.coroutine.resume_err(co) 117 | end 118 | end) 119 | return g.parse_states[co]:yield() 120 | end 121 | 122 | ---Sends update requests to the different parsers. 123 | ---@async 124 | ---@param moving_adjacent? number|boolean 125 | ---@param parse_properties? ParseProperties 126 | local function update_list(moving_adjacent, parse_properties) 127 | msg.verbose('opening directory: ' .. g.state.directory) 128 | 129 | g.state.selected = 1 130 | g.state.selection = {} 131 | 132 | local directory = g.state.directory 133 | local list, opts = scanning.scan_directory(g.state.directory, { source = "browser", properties = parse_properties }) 134 | 135 | --if the running coroutine isn't the one stored in the state variable, then the user 136 | --changed directories while the coroutine was paused, and this operation should be aborted 137 | if coroutine.running() ~= g.state.co then 138 | msg.verbose(g.ABORT_ERROR.msg) 139 | msg.debug("expected:", g.state.directory, "received:", directory) 140 | return 141 | end 142 | 143 | --apply fallbacks if the scan failed 144 | if not list then 145 | msg.warn("could not read directory", g.state.directory) 146 | list, opts = {}, {} 147 | opts.empty_text = g.style.warning..'Error: could not parse directory' 148 | end 149 | 150 | g.state.list = list 151 | g.state.parser = opts.parser 152 | 153 | --setting custom options from parsers 154 | g.state.directory_label = opts.directory_label 155 | g.state.empty_text = opts.empty_text or g.state.empty_text 156 | 157 | --we assume that directory is only changed when redirecting to a different location 158 | --therefore we need to change the `moving_adjacent` flag and clear some state values 159 | if opts.directory then 160 | g.state.directory = opts.directory 161 | moving_adjacent = false 162 | clear_non_adjacent_state(true) 163 | end 164 | 165 | if opts.selected_index then 166 | g.state.selected = opts.selected_index or g.state.selected 167 | if g.state.selected > #g.state.list then g.state.selected = #g.state.list 168 | elseif g.state.selected < 1 then g.state.selected = 1 end 169 | end 170 | 171 | if moving_adjacent then cursor.select_prev_directory() 172 | else cursor.select_playing_item() end 173 | g.state.prev_directory = g.state.directory 174 | end 175 | 176 | ---rescans the folder and updates the list. 177 | ---@param nav_type? NavigationType 178 | ---@param cb? function 179 | ---@param parse_properties? ParseProperties 180 | ---@return thread # The coroutine for the triggered parse operation. May be aborted early if directory is in the cache. 181 | function scanning.rescan(nav_type, cb, parse_properties) 182 | if nav_type == nil then nav_type = NavType.RESCAN end 183 | 184 | --we can only make assumptions about the directory label when moving from adjacent directories 185 | if nav_type == NavType.GOTO or nav_type == NavType.REDIRECT then 186 | clear_non_adjacent_state(nav_type == NavType.GOTO) 187 | end 188 | 189 | g.state.empty_text = "~" 190 | g.state.list = {} 191 | cursor.disable_select_mode() 192 | ass.update_ass() 193 | 194 | --the directory is always handled within a coroutine to allow addons to 195 | --pause execution for asynchronous operations 196 | ---@async 197 | local co = fb_utils.coroutine.queue(function() 198 | update_list(nav_type, parse_properties) 199 | if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end 200 | 201 | ass.update_ass() 202 | if cb then fb_utils.coroutine.run(cb) end 203 | end) 204 | 205 | g.state.co = co 206 | return co 207 | end 208 | 209 | 210 | return scanning 211 | -------------------------------------------------------------------------------- /modules/observers.lua: -------------------------------------------------------------------------------- 1 | local g = require 'modules.globals' 2 | local directory_movement = require 'modules.navigation.directory-movement' 3 | local fb = require 'modules.apis.fb' 4 | local fb_utils = require 'modules.utils' 5 | local ass = require 'modules.ass' 6 | 7 | ---@class observers 8 | local observers ={} 9 | 10 | ---saves the directory and name of the currently playing file 11 | ---@param _ string 12 | ---@param filepath string 13 | function observers.current_directory(_, filepath) 14 | directory_movement.set_current_file(filepath) 15 | end 16 | 17 | ---@param _ string 18 | ---@param device string 19 | function observers.dvd_device(_, device) 20 | if not device or device == "" then device = '/dev/dvd' end 21 | fb.register_directory_mapping(fb_utils.absolute_path(device), '^dvd://.*', true) 22 | end 23 | 24 | ---@param _ string 25 | ---@param device string 26 | function observers.bd_device(_, device) 27 | if not device or device == '' then device = '/dev/bd' end 28 | fb.register_directory_mapping(fb_utils.absolute_path(device), '^bd://.*', true) 29 | end 30 | 31 | ---@param _ string 32 | ---@param device string 33 | function observers.cd_device(_, device) 34 | if not device or device == '' then device = '/dev/cdrom' end 35 | fb.register_directory_mapping(fb_utils.absolute_path(device), '^cdda://.*', true) 36 | end 37 | 38 | ---@param property string 39 | ---@param alignment string 40 | function observers.osd_align(property, alignment) 41 | if property == 'osd-align-x' then g.ALIGN_X = alignment 42 | elseif property == 'osd-align-y' then g.ALIGN_Y = alignment end 43 | 44 | g.style.global = ([[{\an%d}]]):format(g.ASS_ALIGNMENT_MATRIX[g.ALIGN_Y][g.ALIGN_X]) 45 | ass.update_ass() 46 | end 47 | 48 | return observers 49 | -------------------------------------------------------------------------------- /modules/options.lua: -------------------------------------------------------------------------------- 1 | local utils = require 'mp.utils' 2 | local opt = require 'mp.options' 3 | 4 | ---@class options 5 | local o = { 6 | --root directories 7 | root = "~/", 8 | 9 | --automatically detect windows drives and adds them to the root. 10 | auto_detect_windows_drives = true, 11 | 12 | --characters to use as separators 13 | root_separators = ",", 14 | 15 | --number of entries to show on the screen at once 16 | num_entries = 20, 17 | 18 | --number of directories to keep in the history 19 | history_size = 100, 20 | 21 | --wrap the cursor around the top and bottom of the list 22 | wrap = false, 23 | 24 | --only show files compatible with mpv 25 | filter_files = true, 26 | 27 | --recurses directories concurrently when appending items to the playlist 28 | concurrent_recursion = true, 29 | 30 | --maximum number of recursions that can run concurrently 31 | max_concurrency = 16, 32 | 33 | --enable custom keybinds 34 | custom_keybinds = true, 35 | custom_keybinds_file = "~~/script-opts/file-browser-keybinds.json", 36 | 37 | --blacklist compatible files, it's recommended to use this rather than to edit the 38 | --compatible list directly. A comma separated list of extensions without spaces 39 | extension_blacklist = "", 40 | 41 | --add extra file extensions 42 | extension_whitelist = "", 43 | 44 | --files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist 45 | audio_extensions = "mka,dts,dtshd,dts-hd,truehd,true-hd", 46 | 47 | --files with these extensions will be added as additional subtitle tracks instead of appended to the playlist 48 | subtitle_extensions = "etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs", 49 | 50 | --filter dot directories like .config 51 | --most useful on linux systems 52 | ---@type 'auto'|'yes'|'no' 53 | filter_dot_dirs = 'auto', 54 | ---@type 'auto'|'yes'|'no' 55 | filter_dot_files = 'auto', 56 | 57 | --substitute forward slashes for backslashes when appending a local file to the playlist 58 | --potentially useful on windows systems 59 | substitute_backslash = false, 60 | 61 | --interpret backslashes `\` in paths as forward slashes `/` 62 | --this is useful on Windows, which natively uses backslashes. 63 | --As backslashes are valid filename characters in Unix systems this could 64 | --cause mangled paths, though such filenames are rare. 65 | --Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform` 66 | --property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`. 67 | ---@type 'auto'|'yes'|'no' 68 | normalise_backslash = 'auto', 69 | 70 | --a directory cache to improve directory reading time, 71 | --enable if it takes a long time to load directories. 72 | --may cause 'ghost' files to be shown that no-longer exist or 73 | --fail to show files that have recently been created. 74 | cache = false, 75 | 76 | --this option reverses the behaviour of the alt+ENTER keybind 77 | --when disabled the keybind is required to enable autoload for the file 78 | --when enabled the keybind disables autoload for the file 79 | autoload = false, 80 | 81 | --if autoload is triggered by selecting the currently playing file, then 82 | --the current file will have it's watch-later config saved before being closed 83 | --essentially the current file will not be restarted 84 | autoload_save_current = true, 85 | 86 | --when opening the browser in idle mode prefer the current working directory over the root 87 | --note that the working directory is set as the 'current' directory regardless, so `home` will 88 | --move the browser there even if this option is set to false 89 | default_to_working_directory = false, 90 | 91 | --When opening the browser prefer the directory last opened by a previous mpv instance of file-browser. 92 | --Overrides the `default_to_working_directory` option. 93 | --Requires `save_last_opened_directory` to be true. 94 | --Uses the internal `last-opened-directory` addon. 95 | default_to_last_opened_directory = false, 96 | 97 | --Whether to save the last opened directory and the file to save this value in. 98 | save_last_opened_directory = false, 99 | last_opened_directory_file = '~~state/file_browser-last_opened_directory', 100 | 101 | --when moving up a directory do not stop on empty protocol schemes like `ftp://` 102 | --e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://` 103 | skip_protocol_schemes = true, 104 | 105 | --move the cursor to the currently playing item (if available) when the playing file changes 106 | cursor_follows_playing_item = false, 107 | 108 | --Replace the user's home directory with `~/` in the header. 109 | --Uses the internal home-label addon. 110 | home_label = true, 111 | 112 | --map optical device paths to their respective file paths, 113 | --e.g. mapping bd:// to the value of the bluray-device property 114 | map_bd_device = true, 115 | map_dvd_device = true, 116 | map_cdda_device = true, 117 | 118 | --allows custom icons be set for the folder and cursor 119 | --the `\h` character is a hard space to add padding between the symbol and the text 120 | folder_icon = [[{\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h]], 121 | cursor_icon = [[{\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h]], 122 | cursor_icon_flipped = [[{\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 6.86{\p0}\h]], 123 | 124 | --enable addons 125 | addons = true, 126 | addon_directory = "~~/script-modules/file-browser-addons", 127 | 128 | --Enables the internal `ls` addon that parses directories using the `ls` commandline tool. 129 | --Allows directory parsing to run concurrently, which prevents the browser from locking up. 130 | --Automatically disables itself on Windows systems. 131 | ls_parser = true, 132 | 133 | --Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe. 134 | --Allows directory parsing to run concurrently, which prevents the browser from locking up. 135 | --Automatically disables itself on non-Windows systems. 136 | windir_parser = true, 137 | 138 | --directory to load external modules - currently just user-input-module 139 | module_directory = "~~/script-modules", 140 | 141 | --turn the OSC idle screen off and on when opening and closing the browser 142 | toggle_idlescreen = false, 143 | 144 | --Set the current open status of the browser in the `file_browser/open` field of the `user-data` property. 145 | --This property is only available in mpv v0.36+. 146 | set_user_data = true, 147 | 148 | --Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property. 149 | --This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically ignore this option. 150 | set_shared_script_properties = false, 151 | 152 | ---@type 'auto'|'left'|'center'|'right' 153 | align_x = 'left', 154 | ---@type 'auto'|'top'|'center'|'bottom' 155 | align_y = 'top', 156 | 157 | --style settings 158 | format_string_header = [[{\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------]], 159 | format_string_topwrapper = '...', 160 | format_string_bottomwrapper = '...', 161 | 162 | font_bold_header = true, 163 | font_opacity_selection_marker = "99", 164 | 165 | scaling_factor_base = 1, 166 | scaling_factor_header = 1.4, 167 | scaling_factor_wrappers = 1, 168 | 169 | font_name_header = "", 170 | font_name_body = "", 171 | font_name_wrappers = "", 172 | font_name_folder = "", 173 | font_name_cursor = "", 174 | 175 | font_colour_header = "00ccff", 176 | font_colour_body = "ffffff", 177 | font_colour_wrappers = "00ccff", 178 | font_colour_cursor = "00ccff", 179 | font_colour_escape_chars = "413eff", 180 | 181 | font_colour_multiselect = "fcad88", 182 | font_colour_selected = "fce788", 183 | font_colour_playing = "33ff66", 184 | font_colour_playing_multiselected = "22b547" 185 | 186 | } 187 | 188 | opt.read_options(o, 'file_browser') 189 | 190 | ---@diagnostic disable-next-line deprecated 191 | o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set 192 | 193 | return o 194 | -------------------------------------------------------------------------------- /modules/playlist.lua: -------------------------------------------------------------------------------- 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 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 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 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 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 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 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 | } 361 | -------------------------------------------------------------------------------- /modules/script-messages.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | local utils = require 'mp.utils' 4 | 5 | local o = require 'modules.options' 6 | local g = require 'modules.globals' 7 | local fb_utils = require 'modules.utils' 8 | local scanning = require 'modules.navigation.scanning' 9 | 10 | ---@class script_messages 11 | local script_messages = {} 12 | 13 | ---Allows other scripts to request directory contents from file-browser. 14 | ---@param directory string 15 | ---@param response_str string 16 | function script_messages.get_directory_contents(directory, response_str) 17 | ---@async 18 | fb_utils.coroutine.run(function() 19 | if not directory then msg.error("did not receive a directory string"); return end 20 | if not response_str then msg.error("did not receive a response string"); return end 21 | 22 | directory = mp.command_native({"expand-path", directory}, "") --[[@as string]] 23 | if directory ~= "" then directory = fb_utils.fix_path(directory, true) end 24 | msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str)) 25 | 26 | directory = fb_utils.resolve_directory_mapping(directory) 27 | 28 | ---@class OptsWithVersion: Opts 29 | ---@field API_VERSION string? 30 | 31 | ---@type List|nil, OptsWithVersion|Opts|nil 32 | local list, opts = scanning.scan_directory(directory, { source = "script-message" } ) 33 | if opts then opts.API_VERSION = g.API_VERSION end 34 | 35 | local list_str, err = fb_utils.format_json_safe(list) 36 | if not list_str then msg.error(err) end 37 | 38 | local opts_str, err2 = fb_utils.format_json_safe(opts) 39 | if not opts_str then msg.error(err2) end 40 | 41 | mp.commandv("script-message", response_str, list_str or "", opts_str or "") 42 | end) 43 | end 44 | 45 | ---A helper script message for custom keybinds. 46 | ---Substitutes any '=>' arguments for 'script-message'. 47 | ---Makes chaining script-messages much easier. 48 | ---@param ... string 49 | function script_messages.chain(...) 50 | ---@type string[] 51 | local command = table.pack('script-message', ...) 52 | for i, v in ipairs(command) do 53 | if v == '=>' then command[i] = 'script-message' end 54 | end 55 | mp.commandv(table.unpack(command)) 56 | end 57 | 58 | ---A helper script message for custom keybinds. 59 | ---Sends a command after the specified delay. 60 | ---@param delay string 61 | ---@param ... string 62 | ---@return nil 63 | function script_messages.delay_command(delay, ...) 64 | local command = table.pack(...) 65 | local success, err = pcall(mp.add_timeout, fb_utils.evaluate_string('return '..delay), function() mp.commandv(table.unpack(command)) end) 66 | if not success then return msg.error(err) end 67 | end 68 | 69 | ---A helper script message for custom keybinds. 70 | ---Sends a command only if the given expression returns true. 71 | ---@param condition string 72 | ---@param ... string 73 | function script_messages.conditional_command(condition, ...) 74 | local command = table.pack(...) 75 | fb_utils.coroutine.run(function() 76 | if fb_utils.evaluate_string('return '..condition) == true then mp.commandv(table.unpack(command)) end 77 | end) 78 | end 79 | 80 | ---A helper script message for custom keybinds. 81 | ---Extracts lua expressions from the command and evaluates them. 82 | ---Expressions must be surrounded by !{}. Another ! before the { will escape the evaluation. 83 | ---@param ... string 84 | function script_messages.evaluate_expressions(...) 85 | ---@type string[] 86 | local args = table.pack(...) 87 | fb_utils.coroutine.run(function() 88 | for i, arg in ipairs(args) do 89 | args[i] = arg:gsub('(!+)(%b{})', function(lead, expression) 90 | if #lead % 2 == 0 then return string.rep('!', #lead/2)..expression end 91 | 92 | ---@type any 93 | local eval = fb_utils.evaluate_string('return '..expression:sub(2, -2)) 94 | return type(eval) == "table" and utils.to_string(eval) or tostring(eval) 95 | end) 96 | end 97 | 98 | mp.commandv(table.unpack(args)) 99 | end) 100 | end 101 | 102 | ---A helper function for custom-keybinds. 103 | ---Concatenates the command arguments with newlines and runs the 104 | ---string as a statement of code. 105 | ---@param ... string 106 | function script_messages.run_statement(...) 107 | local statement = table.concat(table.pack(...), '\n') 108 | fb_utils.coroutine.run(fb_utils.evaluate_string, statement) 109 | end 110 | 111 | return script_messages 112 | -------------------------------------------------------------------------------- /modules/setup.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | 3 | local o = require 'modules.options' 4 | local g = require 'modules.globals' 5 | local fb_utils = require 'modules.utils' 6 | local fb = require 'modules.apis.fb' 7 | 8 | --sets up the compatible extensions list 9 | local function setup_extensions_list() 10 | --setting up subtitle extensions 11 | for ext in fb_utils.iterate_opt(o.subtitle_extensions:lower(), ',') do 12 | g.sub_extensions[ext] = true 13 | g.extensions[ext] = true 14 | end 15 | 16 | --setting up audio extensions 17 | for ext in fb_utils.iterate_opt(o.audio_extensions:lower(), ',') do 18 | g.audio_extensions[ext] = true 19 | g.extensions[ext] = true 20 | end 21 | 22 | --adding file extensions to the set 23 | for _, ext in ipairs(g.compatible_file_extensions) do 24 | g.extensions[ext] = true 25 | end 26 | 27 | --adding extra extensions on the whitelist 28 | for str in fb_utils.iterate_opt(o.extension_whitelist:lower(), ',') do 29 | g.extensions[str] = true 30 | end 31 | 32 | --removing extensions that are in the blacklist 33 | for str in fb_utils.iterate_opt(o.extension_blacklist:lower(), ',') do 34 | g.extensions[str] = nil 35 | end 36 | end 37 | 38 | --splits the string into a table on the separators 39 | local function setup_root() 40 | for str in fb_utils.iterate_opt(o.root) do 41 | local path = mp.command_native({'expand-path', str}) --[[@as string]] 42 | path = fb_utils.fix_path(path, true) 43 | 44 | local temp = {name = path, type = 'dir', label = str, ass = fb_utils.ass_escape(str, true)} 45 | 46 | g.root[#g.root+1] = temp 47 | end 48 | 49 | if g.PLATFORM == 'windows' then 50 | fb.register_root_item('C:/') 51 | elseif g.PLATFORM ~= nil then 52 | fb.register_root_item('/') 53 | end 54 | end 55 | 56 | ---@class setup 57 | return { 58 | extensions_list = setup_extensions_list, 59 | root = setup_root, 60 | } 61 | -------------------------------------------------------------------------------- /screenshots/bunny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CogentRedTester/mpv-file-browser/dc0de40281abbbbeda75f8d4f7b617584f4b3b4f/screenshots/bunny.png -------------------------------------------------------------------------------- /screenshots/front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CogentRedTester/mpv-file-browser/dc0de40281abbbbeda75f8d4f7b617584f4b3b4f/screenshots/front.png --------------------------------------------------------------------------------