├── .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 | 
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
--------------------------------------------------------------------------------