summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorryukamish <[email protected]>2026-04-11 16:06:45 +0530
committerryukamish <[email protected]>2026-04-11 16:06:45 +0530
commit607891801fbf2051a0c9062d15a445dd238c1e1b (patch)
tree33b8cfba2355b498addea05e385b06d46cced30f
parent6fbde2906e3bb2d4d1df23f6374d8a6a0aef870f (diff)
add: mako and mpv configs
-rw-r--r--mako/config25
-rw-r--r--mpv/input.conf2
-rw-r--r--mpv/mpv.conf21
-rw-r--r--mpv/script-opts/file_browser.conf247
-rw-r--r--mpv/scripts/SmartSkip.lua1383
-rw-r--r--mpv/scripts/file-browser/main.lua76
-rw-r--r--mpv/scripts/file-browser/modules/addons.lua204
-rw-r--r--mpv/scripts/file-browser/modules/addons/cache.lua146
-rw-r--r--mpv/scripts/file-browser/modules/addons/file.lua46
-rw-r--r--mpv/scripts/file-browser/modules/addons/find.lua124
-rw-r--r--mpv/scripts/file-browser/modules/addons/home-label.lua31
-rw-r--r--mpv/scripts/file-browser/modules/addons/last-opened-directory.lua62
-rw-r--r--mpv/scripts/file-browser/modules/addons/ls.lua68
-rw-r--r--mpv/scripts/file-browser/modules/addons/root.lua26
-rw-r--r--mpv/scripts/file-browser/modules/addons/windir.lua218
-rw-r--r--mpv/scripts/file-browser/modules/addons/winroot.lua62
-rw-r--r--mpv/scripts/file-browser/modules/apis/fb.lua198
-rw-r--r--mpv/scripts/file-browser/modules/apis/parse-state.lua34
-rw-r--r--mpv/scripts/file-browser/modules/apis/parser.lua40
-rw-r--r--mpv/scripts/file-browser/modules/ass.lua225
-rw-r--r--mpv/scripts/file-browser/modules/controls.lua94
-rw-r--r--mpv/scripts/file-browser/modules/defs/file-browser.lua3
-rw-r--r--mpv/scripts/file-browser/modules/defs/keybind.lua39
-rw-r--r--mpv/scripts/file-browser/modules/defs/list.lua25
-rw-r--r--mpv/scripts/file-browser/modules/defs/mp/defaults.lua148
-rw-r--r--mpv/scripts/file-browser/modules/defs/mp/input.lua21
-rw-r--r--mpv/scripts/file-browser/modules/defs/mp/msg.lua32
-rw-r--r--mpv/scripts/file-browser/modules/defs/mp/options.lua11
-rw-r--r--mpv/scripts/file-browser/modules/defs/mp/utils.lua43
-rw-r--r--mpv/scripts/file-browser/modules/defs/parser.lua41
-rw-r--r--mpv/scripts/file-browser/modules/defs/state.lua21
-rw-r--r--mpv/scripts/file-browser/modules/defs/user-input.lua28
-rw-r--r--mpv/scripts/file-browser/modules/globals.lua181
-rw-r--r--mpv/scripts/file-browser/modules/keybinds.lua354
-rw-r--r--mpv/scripts/file-browser/modules/navigation/cursor.lua134
-rw-r--r--mpv/scripts/file-browser/modules/navigation/directory-movement.lua209
-rw-r--r--mpv/scripts/file-browser/modules/navigation/scanning.lua210
-rw-r--r--mpv/scripts/file-browser/modules/observers.lua48
-rw-r--r--mpv/scripts/file-browser/modules/options.lua193
-rw-r--r--mpv/scripts/file-browser/modules/playlist.lua360
-rw-r--r--mpv/scripts/file-browser/modules/script-messages.lua111
-rw-r--r--mpv/scripts/file-browser/modules/setup.lua60
-rw-r--r--mpv/scripts/file-browser/modules/utils.lua637
43 files changed, 6241 insertions, 0 deletions
diff --git a/mako/config b/mako/config
new file mode 100644
index 0000000..bba65c9
--- /dev/null
+++ b/mako/config
@@ -0,0 +1,25 @@
+# Global settings
+font=JetBrainsMono Nerd Font 12
+width=300
+margin=10
+padding=10
+border-size=2
+border-radius=5
+default-timeout=5000
+ignore-timeout=0
+max-visible=3
+layer=overlay
+anchor=top-right
+
+background-color=#000000
+
+[urgency=low]
+default-timeout=5000
+border-color=#808080
+
+[urgency=normal]
+default-timeout=10000
+
+[urgency=high]
+default-timeout=50000
+border-color=#ff0000
diff --git a/mpv/input.conf b/mpv/input.conf
new file mode 100644
index 0000000..75bb317
--- /dev/null
+++ b/mpv/input.conf
@@ -0,0 +1,2 @@
+CTRL+RIGHT playlist-next # Next video with Shift+Right
+CTRL+LEFT playlist-prev # Previous video with Shift+Left
diff --git a/mpv/mpv.conf b/mpv/mpv.conf
new file mode 100644
index 0000000..ffec86f
--- /dev/null
+++ b/mpv/mpv.conf
@@ -0,0 +1,21 @@
+# General playback settings
+save-position-on-quit
+resume-playback=yes
+screenshot-format=png
+screenshot-directory=~/Pictures/mpv_screenshots
+
+directory-filter-types=video
+# autocreate-playlist=filter
+keep-open=no
+
+# Volume settings
+volume-max=200
+audio-normalize-downmix=yes
+
+vo=gpu-next
+
+# yt-dlp settings
+cache=yes
+demuxer-cache-wait=yes
+cache-secs=240
+demuxer-max-bytes=1G
diff --git a/mpv/script-opts/file_browser.conf b/mpv/script-opts/file_browser.conf
new file mode 100644
index 0000000..37098ee
--- /dev/null
+++ b/mpv/script-opts/file_browser.conf
@@ -0,0 +1,247 @@
+#######################################################
+# This is the default config file for mpv-file-browser
+# https://github.com/CogentRedTester/mpv-file-browser
+#######################################################
+
+####################################
+######## browser settings ##########
+####################################
+
+# Root directories, separated by commas.
+# `C:/` and `/` are automatically added on Windows and non-windows systems, respectively.
+# The order of automatically added items can be changed by entering them here manually.
+root=~/
+
+# characters to separate root directories, each character works individually
+root_separators=,
+
+# number of entries to show on the screen at once
+num_entries=20
+
+# number of directories to keep in the history.
+# A size of 0 disables the history.
+history_size=100
+
+# wrap the cursor around the top and bottom of the list
+wrap=no
+
+# enables loading external addons
+addons=yes
+
+# enable custom keybinds
+# the keybind json file must go in ~~/script-opts
+custom_keybinds=yes
+
+# Automatically detect windows drives and adds them to the root.
+# Using Ctrl+r in the root will run another scan.
+auto_detect_windows_drives=yes
+
+# when opening the browser in idle mode prefer the current working directory over the root
+# note that the working directory is set as the 'current' directory regardless, so `home` will
+# move the browser there even if this option is set to false
+default_to_working_directory=no
+
+# When opening the browser prefer the directory last opened by a previous mpv instance of file-browser.
+# Overrides the `default_to_working_directory` option.
+# Requires `save_last_opened_directory` to be `yes`.
+# Uses the internal `last-opened-directory` addon.
+default_to_last_opened_directory=no
+
+# Whether to save the last opened directory.
+save_last_opened_directory=no
+
+# Move the cursor to the currently playing item (if available) when the playing file changes.
+cursor_follows_playing_item=no
+
+####################################
+########## filter settings #########
+####################################
+
+# only show files compatible with mpv in the browser
+filter_files=yes
+
+# file-browser only shows files that are compatible with mpv by default
+# adding a file extension to this list will add it to the extension whitelist
+# extensions are separated with commas, do not use any spaces
+extension_whitelist=
+
+# add file extensions to this list to disable default filetypes
+# note that this will also override audio/subtitle_extension options below
+extension_blacklist=
+
+# files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
+# items on this list are automatically added to the extension whitelist
+audio_extensions=mka,dts,dtshd,dts-hd,truehd,true-hd
+
+# files with these extensions will be added as additional subtitle tracks for the current file instead of appended to the playlist
+# items on this list are automatically added to the extension whitelist
+subtitle_extensions=etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs
+
+# filter directories or files starting with a period like .config for linux systems
+# auto will show dot entries on windows and hide them otherwise
+filter_dot_dirs=auto
+filter_dot_files=auto
+
+####################################
+###### file loading settings #######
+####################################
+
+# this option reverses the behaviour of the alt+ENTER keybind
+# when disabled the keybind is required to enable autoload for the file
+# when enabled the keybind disables autoload for the file
+autoload=no
+
+# experimental feature that recurses directories concurrently when appending items to the playlist
+# this feature has the potential for massive performance improvements when using addons with asynchronous IO
+concurrent_recursion=yes
+
+# maximum number of recursions that can run concurrently
+# if this number is too high it risks overflowing the mpv event queue, which will cause some directories to be dropped entirely
+max_concurrency=16
+
+# substitute forward slashes for backslashes when appending a local file to the playlist
+# may be useful on windows systems
+substitute_backslash=no
+
+# if autoload is triggered by selecting the currently playing file, then
+# the current file will have it's watch-later config saved before being closed and re-opened
+# essentially the current file will not be restarted
+autoload_save_current=yes
+
+####################################
+### directory parsing settings #####
+####################################
+
+# a directory cache to improve directory reading time,
+# enable if it takes a long time to load directories.
+# May cause 'ghost' files to be shown that no-longer exist or
+# fail to show files that have recently been created.
+# Reloading the directory with Ctrl+r will never use the cache.
+# Use Ctrl+Shift+r to forcibly clear the cache.
+cache=no
+
+# Enables the internal `ls` addon that parses directories using the `ls` commandline tool.
+# Allows directory parsing to run concurrently, which prevents the browser from locking up.
+# Automatically disables itself on Windows systems.
+ls_parser=yes
+
+# Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe.
+# Allows directory parsing to run concurrently, which prevents the browser from locking up.
+# Automatically disables itself on non-Windows systems.
+windir_parser=yes
+
+# when moving up a directory do not stop on empty protocol schemes like `ftp://`
+# e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://`
+skip_protocol_schemes=yes
+
+# map optical device paths to their respective file paths,
+# e.g. mapping bd:// to the value of the bluray-device property
+map_bd_device=yes
+map_dvd_device=yes
+map_cdda_device=yes
+
+####################################
+########## misc settings ###########
+####################################
+
+# turn the OSC idle screen off and on when opening and closing the browser
+# this should only be enabled if file-browser is the only thing controlling the idle-screen,
+# if multiple sources attempt to control the idle-screen at the same time it can cause unexpected behaviour.
+toggle_idlescreen=no
+
+# interpret backslashes `\` in paths as forward slashes `/`
+# this is useful on Windows, which natively uses backslashes.
+# As backslashes are valid filename characters in Unix systems this could
+# cause mangled paths, though such filenames are rare.
+# Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform`
+# property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`.
+normalise_backslash=auto
+
+# Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
+# This property is only available in mpv v0.36+.
+set_user_data=yes
+
+# Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
+# This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically disable this option.
+set_shared_script_properties=no
+
+####################################
+########## file overrides #########
+####################################
+
+# directory to load external modules - currently just user-input-module
+module_directory=~~/script-modules
+addon_directory=~~/script-modules/file-browser-addons
+custom_keybinds_file=~~/script-opts/file-browser-keybinds.json
+last_opened_directory_file=~~state/file_browser-last_opened_directory
+
+####################################
+######### style settings ###########
+####################################
+
+# Replace the user's home directory with `~/` in the header.
+# Uses the internal home-label addon.
+home_label=yes
+
+# force file-browser to use a specific alignment (default: top-left)
+# set to auto to use the default mpv osd-align options
+# Options: 'auto'|'top'|'center'|'bottom'
+align_y=top
+# Options: 'auto'|'left'|'center'|'right'
+align_x=left
+
+# The format string used for the header. Uses custom-keybind substitution codes to
+# dynamically change the contents of the header (see: docs/custom-keybinds.md#codes)
+# and supports the additional code `%^`which re-applies the default header ass style.
+# The original style used before the current one was: %q\N----------------------------------------------------
+format_string_header={\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------
+
+# The format strings used for the wrappers. Supports custom-keybind substitution codes, and
+# supports two additional codes: `%<` and `%>` to show the number of items before and after the visible list, respectively.
+# Setting these options to empty strings will disable the wrappers.
+# Original styles used before the current ones were:
+# top: %< item(s) above\N
+# bottom: \N%> item(s) remaining
+format_string_topwrapper=...
+format_string_bottomwrapper=...
+
+# allows custom icons be set for the folder and cursor
+# the `\h` character is a hard space to add padding
+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
+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
+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
+
+# set the opacity of fonts in hexadecimal from 00 (opaque) to FF (transparent)
+font_opacity_selection_marker=99
+
+# print the header in bold font
+font_bold_header=yes
+
+# scale the size of the browser; 2 would double the size, 0.5 would halve it, etc.
+# the header and wrapper scaling is relative to the base scaling
+scaling_factor_base=1
+scaling_factor_header=1.4
+scaling_factor_wrappers=1
+
+# set custom font names, blank is the default
+# setting custom fonts for the folder/cursor can fix broken or missing icons
+font_name_header=
+font_name_body=
+font_name_wrappers=
+font_name_folder=
+font_name_cursor=
+
+# set custom font colours
+# colours are in hexadecimal format in Blue Green Red order
+# note that this is the opposite order to RGB colour codes
+font_colour_header=00ccff
+font_colour_body=ffffff
+font_colour_wrappers=00ccff
+font_colour_cursor=00ccff
+font_colour_escape_chars=413eff
+
+# these are colours applied to list items in different states
+font_colour_selected=fce788
+font_colour_multiselect=fcad88
+font_colour_playing=33ff66
+font_colour_playing_multiselected=22b547
diff --git a/mpv/scripts/SmartSkip.lua b/mpv/scripts/SmartSkip.lua
new file mode 100644
index 0000000..a80241a
--- /dev/null
+++ b/mpv/scripts/SmartSkip.lua
@@ -0,0 +1,1383 @@
+-- Copyright (c) 2025, Eisa AlAwadhi
+-- License: BSD 2-Clause License
+-- Creator: Eisa AlAwadhi
+-- Project: SmartSkip
+-- Version: 1.3.4
+-- Date: 14-May-2025
+
+-- Related forked projects:
+-- https://github.com/detuur/mpv-scripts/blob/master/skiptosilence.lua
+-- https://github.com/mar04/chapters_for_mpv
+-- https://github.com/po5/chapterskip/blob/master/chapterskip.lua
+
+local o = {
+ -----Silence Skip Settings-----
+ silence_audio_level = -40,
+ silence_duration = 0.65,
+ ignore_silence_duration=5,
+ min_skip_duration = 0,
+ max_skip_duration = 180,
+ keybind_twice_cancel_skip = true,
+ silence_skip_to_end = "playlist-next",
+ add_chapter_on_skip = true,
+ force_mute_on_skip = false,
+ -----Smart Skip Settings-----
+ last_chapter_skip_behavior=[[ [ ["no-chapters", "silence-skip"], ["internal-chapters", "playlist-next"], ["external-chapters", "silence-skip"] ] ]],
+ smart_next_proceed_countdown = true,
+ smart_prev_cancel_countdown = true,
+ -----Chapters Settings-----
+ external_chapters_autoload = true,
+ modified_chapters_autosave=[[ ["no-chapters", "external-chapters"] ]],
+ global_chapters = true,
+ global_chapters_path = "/:dir%mpvconf%/chapters",
+ hash_global_chapters = true,
+ add_chapter_ask_title = false,
+ add_chapter_pause_for_input = false,
+ add_chapter_placeholder_title = "Chapter ",
+ -----Auto-Skip Settings-----
+ autoskip_chapter = true,
+ autoskip_countdown = 3,
+ autoskip_countdown_bulk = false,
+ autoskip_countdown_graceful = false,
+ skip_once = false,
+ categories=[[ [ ["internal-chapters", "prologue>Prologue/^Intro; opening>^OP/ OP$/^Opening; ending>^ED/ ED$/^Ending/Credits Start; preview>PV/ PV$/^Preview/Preview Start"], ["external-chapters", "idx->0/2"] ] ]],
+ skip=[[ [ ["internal-chapters", "toggle;toggle_idx;opening;ending;preview"], ["external-chapters", "toggle;toggle_idx"] ] ]],
+ invert_autoskip_exclusion = false,
+ autoskip_exclusion=[[
+ [""]
+ ]],
+ -----OSD Messages Settings-----
+ osd_duration = 2500,
+ silence_skip_osd = "osd-msg-bar",
+ chapter_osd = "osd-msg-bar",
+ autoskip_osd = "osd-msg-bar",
+ playlist_osd = true,
+ osd_msg = true,
+ -----Keybind Settings-----
+ toggle_autoskip_keybind=[[ ["ctrl+."] ]],
+ toggle_category_autoskip_keybind=[[ ["alt+."] ]],
+ cancel_autoskip_countdown_keybind=[[ ["esc", "n"] ]],
+ proceed_autoskip_countdown_keybind=[[ ["enter", "y"] ]],
+ add_chapter_keybind=[[ ["n"] ]],
+ remove_chapter_keybind=[[ ["alt+n"] ]],
+ edit_chapter_keybind=[[ [""] ]],
+ write_chapters_keybind=[[ ["ctrl+n"] ]],
+ bake_chapters_keybind=[[ [""] ]],
+ chapter_next_keybind=[[ ["ctrl+right"] ]],
+ chapter_prev_keybind=[[ ["ctrl+left"] ]],
+ smart_next_keybind=[[ [">"] ]],
+ smart_prev_keybind=[[ ["<"] ]],
+ silence_skip_keybind=[[ ["?"] ]],
+ cancel_silence_skip_keybind=[[ ["esc"] ]],
+}
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+local options = require 'mp.options'
+options.read_options(o)
+
+if o.add_chapter_on_skip ~= false and o.add_chapter_on_skip ~= true then o.add_chapter_on_skip = utils.parse_json(o.add_chapter_on_skip) end
+if o.modified_chapters_autosave ~= false and o.modified_chapters_autosave ~= true then o.modified_chapters_autosave = utils.parse_json(o.modified_chapters_autosave) end
+o.last_chapter_skip_behavior = utils.parse_json(o.last_chapter_skip_behavior)
+if utils.parse_json(o.skip) ~= nil then o.skip = utils.parse_json(o.skip) end
+if utils.parse_json(o.categories) ~= nil then o.categories = utils.parse_json(o.categories) end
+if o.skip_once ~= false and o.skip_once ~= true then o.skip_once = utils.parse_json(o.skip_once) end
+
+if o.global_chapters_path:match('/:dir%%mpvconf%%') then
+ o.global_chapters_path = o.global_chapters_path:gsub('/:dir%%mpvconf%%', mp.find_config_file('.'))
+elseif o.global_chapters_path:match('/:dir%%script%%') then
+ o.global_chapters_path = o.global_chapters_path:gsub('/:dir%%script%%', debug.getinfo(1).source:match('@?(.*/)'))
+elseif o.global_chapters_path:match('/:var%%(.*)%%') then
+ local os_variable = o.global_chapters_path:match('/:var%%(.*)%%')
+ o.global_chapters_path = o.global_chapters_path:gsub('/:var%%(.*)%%', os.getenv(os_variable))
+end
+
+o.toggle_autoskip_keybind = utils.parse_json(o.toggle_autoskip_keybind)
+o.cancel_autoskip_countdown_keybind = utils.parse_json(o.cancel_autoskip_countdown_keybind)
+o.proceed_autoskip_countdown_keybind = utils.parse_json(o.proceed_autoskip_countdown_keybind)
+o.toggle_category_autoskip_keybind = utils.parse_json(o.toggle_category_autoskip_keybind)
+o.add_chapter_keybind = utils.parse_json(o.add_chapter_keybind)
+o.remove_chapter_keybind = utils.parse_json(o.remove_chapter_keybind)
+o.write_chapters_keybind = utils.parse_json(o.write_chapters_keybind)
+o.edit_chapter_keybind = utils.parse_json(o.edit_chapter_keybind)
+o.bake_chapters_keybind = utils.parse_json(o.bake_chapters_keybind)
+o.chapter_prev_keybind = utils.parse_json(o.chapter_prev_keybind)
+o.chapter_next_keybind = utils.parse_json(o.chapter_next_keybind)
+o.smart_prev_keybind = utils.parse_json(o.smart_prev_keybind)
+o.smart_next_keybind = utils.parse_json(o.smart_next_keybind)
+o.silence_skip_keybind = utils.parse_json(o.silence_skip_keybind)
+o.cancel_silence_skip_keybind = utils.parse_json(o.cancel_silence_skip_keybind)
+o.autoskip_exclusion = utils.parse_json(o.autoskip_exclusion) --1.3.4# option autoskip_exclusion parsing
+
+package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"}) .. package.path
+local user_input_module, input = pcall(require, "user-input-module")
+
+mp.set_property('user-data/smartskip/silence-skip', 'no')
+
+if o.osd_duration == -1 then o.osd_duration = (mp.get_property_number('osd-duration') or 1000) end
+local speed_state = 1
+local pause_state = false
+local mute_state = false
+local sub_state = nil
+local secondary_sub_state = nil
+local vid_state = nil
+local skip_flag = false
+local window_state = nil
+local force_silence_skip = false
+local initial_skip_time = 0
+local initial_chapter_count = 0
+local chapter_state = 'no-chapters'
+local file_length = 0
+local keep_open_state = "yes"
+if mp.get_property("config") ~= "no" then keep_open_state = mp.get_property("keep-open") end
+local osd_duration_default = (mp.get_property_number('osd-duration') or 1000)
+local geometry_default = mp.get_property_native("geometry")
+local autoskip_chapter = o.autoskip_chapter
+local playlist_osd = false
+local autoskip_playlist_osd = false
+local g_playlist_pos = 0
+local g_opt_categories = o.categories
+local g_opt_skip_once = false
+o.autoskip_countdown = math.floor(o.autoskip_countdown)
+local g_autoskip_countdown = o.autoskip_countdown
+local g_autoskip_countdown_flag = false
+local g_protocols = {'https?:', 'magnet:', 'rtmps?:', 'smb:', 'ftps?:', 'sftp:'} --1.3.4# default global protocols needed to check for protocols / website exclusion
+local g_filepath = '' --1.3.4# define initial global variable for filepath
+local g_autoskip_disabled = false --1.3.4# set global variable for autoskip disabled to be used for autoskip exclusion_check function
+local categories = {
+ toggle = "",
+ toggle_idx = "",
+}
+
+-- utility functions --
+function has_value(tab, val, array2d)
+ if not tab then return msg.error('check value passed') end
+ if not val then return msg.error('check value passed') end
+ if not array2d then
+ for index, value in ipairs(tab) do
+ if string.lower(value) == string.lower(val) then
+ return true
+ end
+ end
+ end
+ if array2d then
+ for i=1, #tab do
+ if tab[i] and string.lower(tab[i][array2d]) == string.lower(val) then
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+function esc_string(str)
+ return str:gsub("([%p])", "%%%1")
+end
+
+function esc_lua_pattern(str) --1.3.4# helper function to escape pattern needed for exclusion_check function
+ return str:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
+end
+
+function starts_protocol(tab, val) --1.3.4# helper function to check if file is protocol needed for exclusion_check function
+ for index, value in ipairs(tab) do
+ if (val:find(value) == 1) then
+ return true
+ end
+ end
+ return false
+end
+
+function prompt_msg(text, duration, osd)
+ if not text then return end
+ msg.info(text)
+
+ if not o.osd_msg then return end
+ if osd == "no-osd" or osd == "osd-bar" then return end
+
+ if not duration then duration = o.osd_duration end
+ mp.commandv("show-text", text, duration)
+end
+
+function prompt_progress(osd, duration)
+ if not o.osd_msg or not osd then return end
+ if osd == "no-osd" then return end
+
+ if not duration then duration = o.osd_duration end
+ mp.set_property("osd-duration", o.osd_duration)
+ mp.commandv(osd, "show-progress")
+ mp.add_timeout(0.07, function () mp.set_property('osd-duration', osd_duration_default) end)
+end
+
+function bind_keys(keys, name, func, opts)
+ if not keys then
+ mp.add_forced_key_binding(keys, name, func, opts)
+ return
+ end
+
+ for i = 1, #keys do
+ if i == 1 then
+ mp.add_forced_key_binding(keys[i], name, func, opts)
+ else
+ mp.add_forced_key_binding(keys[i], name .. i, func, opts)
+ end
+ end
+end
+
+function unbind_keys(keys, name)
+ if not keys then
+ mp.remove_key_binding(name)
+ return
+ end
+
+ for i = 1, #keys do
+ if i == 1 then
+ mp.remove_key_binding(name)
+ else
+ mp.remove_key_binding(name .. i)
+ end
+ end
+end
+
+function exclusion_check()
+ if g_filepath == "" then return false end
+ if o.autoskip_exclusion == nil then msg.warn('ignoring autoskip_exclusion option due to an inappropriate value') return false end
+ if not o.autoskip_exclusion[1] or #o.autoskip_exclusion == 1 and o.autoskip_exclusion[1] == "" then return false end
+
+ local invertable_return = {true, false}
+ local exclusion_msg = 'Auto-Skip disabled because of exclusion'
+ if o.invert_autoskip_exclusion then
+ invertable_return = {false, true}
+ exclusion_msg = 'Auto-Skip enabled because of inclusion'
+ end
+
+ if has_value(o.autoskip_exclusion, g_filepath, nil) then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ elseif not starts_protocol(g_protocols, g_filepath) then
+ if has_value(o.autoskip_exclusion, g_filepath:match('^(.-)([^\\/]-)%.([^\\/%.]-)%.?$'), nil) or
+ has_value(o.autoskip_exclusion, g_filepath:match('^(.-)([^\\/]-)%.([^\\/%.]-)%.?$'):gsub('\\$', ''), nil) then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ elseif has_value(o.autoskip_exclusion, g_filepath:match('%.([^%.]+)$'), nil) or
+ has_value(o.autoskip_exclusion, "."..g_filepath:match('%.([^%.]+)$'), nil) then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ else
+ for i=1, #o.autoskip_exclusion do
+ local esc_autoskip_exclusion = esc_lua_pattern(o.autoskip_exclusion[i])
+ if string.lower(g_filepath):match(string.lower(esc_autoskip_exclusion)) and o.autoskip_exclusion[i]:sub(-2) == '\\*' and string.lower(o.autoskip_exclusion[i]:sub(1, -2)) ~= string.lower(g_filepath):match("(.*[\\/])") then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ end
+ end
+ end
+ elseif starts_protocol(g_protocols, g_filepath) then
+ if has_value(o.autoskip_exclusion, g_filepath:match('(.-)(:)'), nil) or
+ has_value(o.autoskip_exclusion, g_filepath:match('(.-:)'), nil) or
+ has_value(o.autoskip_exclusion, g_filepath:match('(.-:/?/?)'), nil) then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ elseif g_filepath:find('https?://') == 1 then
+ local difchk_1, difchk_2 = g_filepath:match("(https?://)w?w?w?%.?([%w%.%:]*)")
+ local different_check_temp = difchk_1..difchk_2
+ local different_checks = {different_check_temp, g_filepath:match("https?://w?w?w?%.?([%w%.%:]*)"), g_filepath:match("https?://([%w%.%:]*)"), g_filepath:match("(https?://[%w%.%:]*)") }
+ for i = 1, #different_checks do
+ if different_checks[i] and has_value(o.autoskip_exclusion, different_checks[i], nil)
+ or different_checks[i]..'/' and has_value(o.autoskip_exclusion, different_checks[i]..'/', nil) then
+ msg.info(exclusion_msg)
+ return invertable_return[1]
+ end
+ end
+ end
+ end
+
+ return invertable_return[2]
+end
+
+-- skip-silence utility functions --
+function restoreProp(timepos,pause)
+ if not timepos then timepos = mp.get_property_number("time-pos") end
+ if not pause then pause = pause_state end
+
+ mp.set_property("vid", vid_state)
+ mp.set_property("force-window", window_state)
+ mp.set_property_bool("mute", mute_state)
+ mp.set_property("speed", speed_state)
+ mp.unobserve_property(foundSilence)
+ mp.command("no-osd af remove @skiptosilence")
+ mp.set_property_bool("pause", pause)
+ mp.set_property_number("time-pos", timepos)
+ mp.set_property("sub-visibility", sub_state)
+ mp.set_property("secondary-sub-visibility", secondary_sub_state)
+ unbind_keys(o.cancel_silence_skip_keybind, 'cancel-silence-skip')
+ mp.set_property('user-data/smartskip/silence-skip', 'no')
+ timer:kill()
+ skip_flag = false
+end
+
+function handleMinMaxDuration(timepos)
+ if not skip_flag then return end
+ if not timepos then timepos = mp.get_property_number("time-pos") end
+
+ skip_duration = timepos - initial_skip_time
+ if o.min_skip_duration > 0 and skip_duration <= o.min_skip_duration then
+ restoreProp(initial_skip_time)
+ prompt_msg('Skipping Cancelled\nSilence less than minimum', o.osd_duration, o.silence_skip_osd)
+ return true
+ end
+ if o.max_skip_duration > 0 and skip_duration >= o.max_skip_duration then
+ restoreProp(initial_skip_time)
+ prompt_msg('Skipping Cancelled\nSilence is more than configured maximum', o.osd_duration, o.silence_skip_osd)
+ return true
+ end
+ return false
+end
+
+function setKeepOpenState()
+ if o.silence_skip_to_end == "playlist-next" then
+ mp.set_property("keep-open", "yes")
+ else
+ mp.set_property("keep-open", "always")
+ end
+end
+
+function eofHandler(name, val)
+ if val and skip_flag then
+ if o.silence_skip_to_end == 'playlist-next' then
+ restoreProp((mp.get_property_native('duration') or 0))
+ if mp.get_property_native('playlist-playing-pos')+1 == mp.get_property_native('playlist-count') then
+ prompt_msg('Skipped to end at ' .. mp.get_property_osd('duration'), o.osd_duration, o.silence_skip_osd)
+ else
+ mp.commandv("playlist-next")
+ end
+ elseif o.silence_skip_to_end == 'cancel' then
+ prompt_msg('Skipping Cancelled\nSilence not detected', o.osd_duration, o.silence_skip_osd)
+ restoreProp(initial_skip_time)
+ elseif o.silence_skip_to_end == 'pause' then
+ prompt_msg('Skipped to end at ' .. mp.get_property_osd('duration'), o.osd_duration, o.silence_skip_osd)
+ restoreProp((mp.get_property_native('duration') or 0), true)
+ end
+ end
+end
+
+-- smart-skip main code --
+function smartNext()
+ if g_autoskip_countdown_flag and o.smart_next_proceed_countdown then proceed_autoskip(true) return end
+ local next_action = "silence-skip"
+ local chapters_count = (mp.get_property_number('chapters') or 0)
+ local chapter = (mp.get_property_number('chapter') or 0)
+ local current_playlist = (mp.get_property_native('playlist-playing-pos')+1 or 0)
+ local total_playlist = (mp.get_property_native('playlist-count') or 0)
+
+ if chapter+2 <= chapters_count then
+ next_action = 'chapter-next'
+ elseif chapter+2 > chapters_count and (initial_chapter_count == 0 or chapters_count == 0 or force_silence_skip) then
+ if chapters_count == 0 then force_silence_skip = true end
+ next_action = 'silence-skip'
+ elseif chapter+1 >= chapters_count then
+ for i = 1, #o.last_chapter_skip_behavior do
+ if o.last_chapter_skip_behavior[i] and o.last_chapter_skip_behavior[i][1] == chapter_state then
+ next_action = o.last_chapter_skip_behavior[i][2]
+ break
+ end
+ end
+ end
+
+ if next_action == 'playlist-next' and current_playlist == total_playlist then
+ next_action = 'chapter-next'
+ end
+
+ if next_action == 'silence-skip' then
+ silenceSkip()
+ end
+ if next_action == 'chapter-next' then
+ mp.set_property('osd-duration', o.osd_duration)
+ mp.commandv(o.chapter_osd, 'add', 'chapter', 1)
+ mp.add_timeout(0.07, function () mp.set_property('osd-duration', osd_duration_default) end)
+ end
+ if next_action == 'playlist-next' then
+ mp.command('playlist_next')
+ end
+end
+
+function smartPrev()
+ if skip_flag then restoreProp(initial_skip_time) return end
+ if g_autoskip_countdown_flag and o.smart_prev_cancel_countdown then kill_chapterskip_countdown('osd') return end
+ local chapters_count = (mp.get_property_number('chapters') or 0)
+ local chapter = (mp.get_property_number('chapter') or 0)
+ local timepos = (mp.get_property_native("time-pos") or 0)
+
+ if chapter-1 < 0 and timepos > 1 and chapters_count == 0 then
+ mp.commandv('seek', 0, 'absolute', 'exact')
+ prompt_progress(o.chapter_osd, o.osd_duration)
+ elseif chapter-1 < 0 and timepos < 1 then
+ mp.command('playlist_prev')
+ elseif chapter-1 <= chapters_count then
+ mp.set_property('osd-duration', o.osd_duration)
+ mp.commandv(o.chapter_osd, 'add', 'chapter', -1)
+ mp.add_timeout(0.07, function () mp.set_property('osd-duration', osd_duration_default) end)
+ end
+end
+
+-- chapter-next/prev main code --
+function chapterSeek(direction)
+ if skip_flag and direction == -1 then restoreProp(initial_skip_time) return end
+
+ local chapters_count = (mp.get_property_number('chapters') or 0)
+ local chapter = (mp.get_property_number('chapter') or 0)
+ local timepos = (mp.get_property_native("time-pos") or 0)
+
+ if chapter+direction < 0 and timepos > 1 and chapters_count == 0 then
+ mp.commandv('seek', 0, 'absolute', 'exact')
+ prompt_progress(o.chapter_osd, o.osd_duration)
+ elseif chapter+direction < 0 and timepos < 1 then
+ mp.command('playlist_prev')
+ elseif chapter+direction >= chapters_count then
+ mp.command('playlist_next')
+ else
+ mp.set_property('osd-duration', o.osd_duration)
+ mp.commandv(o.chapter_osd, 'add', 'chapter', direction)
+ mp.add_timeout(0.07, function () mp.set_property('osd-duration', osd_duration_default) end)
+ end
+end
+
+-- silence skip main code --
+function silenceSkip(action)
+ bind_keys(o.cancel_silence_skip_keybind, "cancel-silence-skip", function() restoreProp(initial_skip_time) return end)
+ if skip_flag then if o.keybind_twice_cancel_skip then restoreProp(initial_skip_time) end return end
+ initial_skip_time = (mp.get_property_native("time-pos") or 0)
+ if math.floor(initial_skip_time) == math.floor(mp.get_property_native('duration') or 0) then return end
+ mp.set_property('user-data/smartskip/silence-skip', 'yes')
+ kill_chapterskip_countdown()
+
+ local width = mp.get_property_native("osd-width")
+ local height = mp.get_property_native("osd-height")
+ local window_maximized = mp.get_property_native("window-maximized")
+
+ window_state = mp.get_property("force-window")
+ vid_state = mp.get_property("vid")
+ sub_state = mp.get_property("sub-visibility")
+ secondary_sub_state = mp.get_property("secondary-sub-visibility")
+ pause_state = mp.get_property_native("pause")
+ speed_state = mp.get_property_native("speed")
+
+ if not window_maximized then mp.set_property_native("geometry", ("%dx%d"):format(width, height)) end
+
+ if o.silence_skip_osd ~= 'no-osd' then mp.commandv(o.silence_skip_osd, "show-progress") end
+
+ mp.command(
+ "no-osd af add @skiptosilence:lavfi=[silencedetect=noise=" ..
+ o.silence_audio_level .. "dB:d=" .. o.silence_duration .. "]"
+ )
+
+ mp.observe_property("af-metadata/skiptosilence", "string", foundSilence)
+ mp.set_property("sub-visibility", "no")
+ mp.set_property("secondary-sub-visibility", "no")
+ mp.set_property("force-window", "yes")
+ mp.set_property("vid", "no")
+ mute_state = mp.get_property_native("mute")
+ if o.force_mute_on_skip then
+ mp.set_property_bool("mute", true)
+ end
+ mp.set_property_bool("pause", false)
+ mp.set_property("speed", 100)
+ setKeepOpenState()
+ skip_flag = true
+
+ timer = mp.add_periodic_timer(0.5, function()
+ local video_time = (mp.get_property_native("time-pos") or 0)
+ handleMinMaxDuration(video_time)
+ if skip_flag and o.silence_skip_osd ~= 'no-osd' then mp.commandv(o.silence_skip_osd, "show-progress") end
+ end)
+end
+
+function foundSilence(name, value)
+ if value == "{}" or value == nil then
+ return
+ end
+
+ timecode = tonumber(string.match(value, "%d+%.?%d+"))
+ if timecode == nil or timecode < initial_skip_time + o.ignore_silence_duration then
+ return
+ end
+
+ if handleMinMaxDuration(timecode) then return end
+
+ restoreProp(timecode)
+
+ mp.add_timeout(0.05, function() prompt_msg('Skipped to silence 🕒 ' .. mp.get_property_osd("time-pos"), o.osd_duration, o.silence_skip_osd) end)
+ if o.add_chapter_on_skip == true or has_value(o.add_chapter_on_skip, chapter_state) then
+ mp.add_timeout(0.05, add_chapter)
+ end
+ skip_flag = false
+end
+
+-- modified fork of chapters_for_mpv --
+--[[
+Copyright (c) 2023 Mariusz Libera <[email protected]>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+--]]
+
+
+-- to debug run mpv with arg: --msg-level=SmartSkip=debug
+-- to test o.run mpv with arg: --script-opts=SmartSkip-OPTION=VALUE
+
+
+
+local chapters_modified = false
+
+msg.debug("options:", utils.to_string(options))
+
+
+-- CHAPTER MANIPULATION --------------------------------------------------------
+
+
+function change_title_callback(user_input, err, chapter_index)
+ if user_input == nil or err ~= nil then
+ msg.warn("no chapter title provided:", err)
+ return
+ end
+
+ local chapter_list = mp.get_property_native("chapter-list")
+
+ if chapter_index > mp.get_property_number("chapter-list/count") then
+ msg.warn("can't set chapter title")
+ return
+ end
+
+ chapter_list[chapter_index].title = user_input
+
+ mp.set_property_native("chapter-list", chapter_list)
+ chapters_modified = true
+end
+
+
+function edit_chapter()
+ local mpv_chapter_index = mp.get_property_number("chapter")
+ local chapter_list = mp.get_property_native("chapter-list")
+
+ if mpv_chapter_index == nil or mpv_chapter_index == -1 then
+ msg.verbose("no chapter selected, nothing to edit")
+ return
+ end
+
+ if not user_input_module then
+ msg.error("no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input")
+ return
+ end
+ input.get_user_input(change_title_callback, {
+ request_text = "title of the chapter:",
+ default_input = chapter_list[mpv_chapter_index + 1].title,
+ cursor_pos = #(chapter_list[mpv_chapter_index + 1].title) + 1,
+ }, mpv_chapter_index + 1)
+
+ if o.add_chapter_pause_for_input then
+ mp.set_property_bool("pause", true)
+ mp.osd_message(" ", 0.1)
+ end
+end
+
+
+function add_chapter(timepos)
+ if not timepos then timepos = mp.get_property_number("time-pos") end
+ local chapter_list = mp.get_property_native("chapter-list")
+
+ if #chapter_list > 0 then
+ for i = 1, #chapter_list do
+ if math.floor(chapter_list[i].time) == math.floor(timepos) then
+ msg.debug("failed to add chapter, chapter exists in same position")
+ return
+ end
+ end
+ end
+
+ local chapter_index = (mp.get_property_number("chapter") or -1) + 2
+
+ table.insert(chapter_list, chapter_index, {title = "", time = timepos})
+
+ msg.debug("inserting new chapter at ", chapter_index, " chapter_", " time: ", timepos)
+
+ mp.set_property_native("chapter-list", chapter_list)
+ chapters_modified = true
+
+ if o.add_chapter_ask_title then
+ if not user_input_module then
+ msg.error("no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input")
+ return
+ end
+ -- ask user for chapter title
+ input.get_user_input(change_title_callback, {
+ request_text = "title of the chapter:",
+ default_input = o.placeholder_title .. chapter_index,
+ cursor_pos = #(o.placeholder_title .. chapter_index) + 1,
+ }, chapter_index)
+
+ if o.add_chapter_pause_for_input then
+ mp.set_property_bool("pause", true)
+ -- FIXME: for whatever reason osd gets hidden when we pause the
+ -- playback like that, workaround to make input prompt appear
+ -- right away without requiring mouse or keyboard action
+ mp.osd_message(" ", 0.1)
+ end
+ end
+end
+
+
+function remove_chapter()
+ local chapter_count = mp.get_property_number("chapter-list/count")
+
+ if chapter_count < 1 then
+ msg.verbose("no chapters to remove")
+ return
+ end
+
+ local chapter_list = mp.get_property_native("chapter-list")
+ local current_chapter = mp.get_property_number("chapter") + 1
+
+ table.remove(chapter_list, current_chapter)
+ msg.debug("removing chapter", current_chapter)
+
+ mp.set_property_native("chapter-list", chapter_list)
+ chapters_modified = true
+end
+
+
+-- UTILITY FUNCTIONS -----------------------------------------------------------
+
+
+function detect_os()
+ if package.config:sub(1,1) == "/" then
+ return "unix"
+ else
+ return "windows"
+ end
+end
+
+
+-- for unix use only
+-- returns a table of command path and varargs, or nil if command was not found
+function command_exists(command, ...)
+ msg.debug("looking for command:", command)
+ -- msg.debug("args:", )
+ local process = mp.command_native({
+ name = "subprocess",
+ capture_stdout = true,
+ capture_stderr = true,
+ playback_only = false,
+ args = {"sh", "-c", "command -v -- " .. command}
+ })
+
+ if process.status == 0 then
+ local command_path = process.stdout:gsub("\n", "")
+ msg.debug("command found:", command_path)
+ return {command_path, ...}
+ else
+ msg.debug("command not found:", command)
+ return nil
+ end
+end
+
+function mkdir(path)
+ local args = nil
+
+ if detect_os() == "unix" then
+ args = {"mkdir", "-p", "--", path}
+ else
+ args = {"powershell", "-NoProfile", "-Command", "mkdir", path}
+ end
+
+ local process = mp.command_native({
+ name = 'subprocess',
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ args = args,
+ })
+
+ if process.status == 0 then
+ msg.debug("mkdir success:", path)
+ return true
+ else
+ msg.error("mkdir failure:", path)
+ return false
+ end
+end
+
+
+-- returns md5 hash of the full path of the current media file
+function hash()
+ local path = mp.get_property("path")
+ if path == nil then
+ msg.debug("something is wrong with the path, can't get full_path, can't hash it")
+ return
+ end
+
+ msg.debug("hashing:", path)
+
+ local cmd = {
+ name = 'subprocess',
+ capture_stdout = true,
+ playback_only = false,
+ }
+ local args = nil
+
+ if detect_os() == "unix" then
+ local md5 = command_exists("md5sum") or command_exists("md5") or command_exists("openssl", "md5 | cut -d ' ' -f 2")
+ if md5 == nil then
+ msg.warn("no md5 command found, can't generate hash")
+ return
+ end
+ md5 = table.concat(md5, " ")
+ cmd["stdin_data"] = path
+ args = {"sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" }
+ else --windows
+ -- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3
+ local hash_command ="$s = [System.IO.MemoryStream]::new(); $w = [System.IO.StreamWriter]::new($s); $w.write(\"" .. path .. "\"); $w.Flush(); $s.Position = 0; Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash"
+ args = {"powershell", "-NoProfile", "-Command", hash_command}
+ end
+ cmd["args"] = args
+ msg.debug("hash cmd:", utils.to_string(cmd))
+ local process = mp.command_native(cmd)
+
+ if process.status == 0 then
+ local hash = process.stdout:gsub("%s+", "")
+ msg.debug("hash:", hash)
+ return hash
+ else
+ msg.warn("hash function failed")
+ return
+ end
+end
+
+function construct_ffmetadata()
+ local path = mp.get_property("path")
+ if path == nil then
+ msg.debug("something is wrong with the path, can't get full_path")
+ return
+ end
+
+ local chapter_count = mp.get_property_number("chapter-list/count")
+ local all_chapters = mp.get_property_native("chapter-list")
+
+ local ffmetadata = ";FFMETADATA1\n;file=" .. path
+
+ for i, c in ipairs(all_chapters) do
+ local c_title = c.title
+ local c_start = c.time * 1000000000
+ local c_end
+
+ if i < chapter_count then
+ c_end = all_chapters[i+1].time * 1000000000
+ else
+ c_end = (mp.get_property_number("duration") or c.time) * 1000000000
+ end
+
+ msg.debug(i, "c_title", c_title, "c_start:", c_start, "c_end", c_end)
+
+ ffmetadata = ffmetadata .. "\n[CHAPTER]\nSTART=" .. c_start .. "\nEND=" .. c_end .. "\ntitle=" .. c_title
+ end
+
+ return ffmetadata
+end
+
+
+-- FILE IO ---------------------------------------------------------------------
+
+
+-- args:
+-- osd - if true, display an osd message
+-- force -- if true write chapters file even if there are no changes
+-- on success returns path of the chapters file, nil on failure
+function write_chapters(...)
+ local chapters_count = (mp.get_property_number('chapters') or 0)
+ local osd, force = ...
+ if not force and not chapters_modified then
+ msg.debug("nothing to write")
+ return
+ end
+ if initial_chapter_count == 0 and chapters_count == 0 then return end
+
+
+ -- figure out the directory
+ local chapters_dir
+ if o.global_chapters then
+ local dir = utils.file_info(o.global_chapters_path)
+ if dir then
+ if dir.is_dir then
+ msg.debug("o.global_chapters_path exists:", o.global_chapters_path)
+ chapters_dir = o.global_chapters_path
+ else
+ msg.error("o.global_chapters_path is not a directory")
+ return
+ end
+ else
+ msg.verbose("o.global_chapters_path doesn't exists:", o.global_chapters_path)
+ if mkdir(o.global_chapters_path) then
+ chapters_dir = o.global_chapters_path
+ else
+ return
+ end
+ end
+ else
+ chapters_dir = utils.split_path(mp.get_property("path"))
+ end
+
+ -- and the name
+ local name = mp.get_property("filename")
+ if o.hash_global_chapters and o.global_chapters then
+ name = hash()
+ if name == nil then
+ msg.warn("hash function failed, fallback to filename")
+ name = mp.get_property("filename")
+ end
+ end
+
+ local chapters_file_path = utils.join_path(chapters_dir, name .. ".ffmetadata")
+ --1.09#HERE I SHOULD ADD SOME SORT OF DELETE FUNCTION IN CASE CHAPTER COUNT IS 0
+ msg.debug("opening for writing:", chapters_file_path)
+ local chapters_file = io.open(chapters_file_path, "w")
+ if chapters_file == nil then
+ msg.error("could not open chapter file for writing")
+ return
+ end
+
+ local success, error = chapters_file:write(construct_ffmetadata())
+ chapters_file:close()
+
+ if success then
+ if osd then
+ prompt_msg('Chapters written to:' .. chapters_file_path)
+ end
+ return chapters_file_path
+ else
+ msg.error("error writing chapters file:", error)
+ return
+ end
+end
+
+function load_chapters()
+ local path = mp.get_property("path")
+ local expected_chapters_file = utils.join_path(utils.split_path(path), mp.get_property("filename") .. ".ffmetadata")
+
+ msg.debug("looking for:", expected_chapters_file)
+
+ local file = utils.file_info(expected_chapters_file)
+
+ if file then
+ msg.debug("found in the local directory, loading..")
+ mp.set_property("file-local-options/chapters-file", expected_chapters_file)
+ chapter_state = 'external-chapters'
+ return
+ end
+
+ if not o.global_chapters then
+ msg.debug("not in local, global chapters not enabled, aborting search")
+ return
+ end
+
+ msg.debug("looking in the global directory")
+
+ if o.hash_global_chapters then
+ local hashed_path = hash()
+ if hashed_path then
+ expected_chapters_file = utils.join_path(o.global_chapters_path, hashed_path .. ".ffmetadata")
+ else
+ msg.debug("hash function failed, fallback to path")
+ expected_chapters_file = utils.join_path(o.global_chapters_path, mp.get_property("filename") .. ".ffmetadata")
+ end
+ else
+ expected_chapters_file = utils.join_path(o.global_chapters_path, mp.get_property("filename") .. ".ffmetadata")
+ end
+
+ msg.debug("looking for:", expected_chapters_file)
+
+ file = utils.file_info(expected_chapters_file)
+
+ if file then
+ msg.debug("found in the global directory, loading..")
+ mp.set_property("file-local-options/chapters-file", expected_chapters_file)
+ chapter_state = 'external-chapters'
+ return
+ end
+
+ msg.debug("chapters file not found")
+end
+
+
+function bake_chapters()
+ if mp.get_property_number("chapter-list/count") == 0 then
+ msg.verbose("no chapters present")
+ return
+ end
+
+ local chapters_file_path = write_chapters(false, true)
+ if not chapters_file_path then
+ msg.error("no chapters file")
+ return
+ end
+
+ local filename = mp.get_property("filename")
+ local output_name
+
+ -- extract file extension
+ local reverse_dot_index = filename:reverse():find(".", 1, true)
+ if reverse_dot_index == nil then
+ msg.warning("file has no extension, fallback to .mkv")
+ output_name = filename .. ".chapters.mkv"
+ else
+ local dot_index = #filename + 1 - reverse_dot_index
+ local ext = filename:sub(dot_index + 1)
+ msg.debug("ext:", ext)
+ if ext ~= "mkv" and ext ~= "mp4" and ext ~= "webm" then
+ msg.debug("fallback to .mkv")
+ ext = "mkv"
+ end
+ output_name = filename:sub(1, dot_index) .. "chapters." .. ext
+ end
+
+ local file_path = mp.get_property("path")
+ local output_path = utils.join_path(utils.split_path(file_path), output_name)
+
+ local args = {"ffmpeg", "-y", "-i", file_path, "-i", chapters_file_path, "-map_metadata", "1", "-codec", "copy", output_path}
+
+ msg.debug("args:", utils.to_string(args))
+
+ local process = mp.command_native({
+ name = 'subprocess',
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ args = args
+ })
+
+ if process.status == 0 then
+ prompt_msg('file written to ' .. output_path)
+ else
+ msg.error("failed to write file:\n", process.stderr)
+ end
+end
+
+--modified fork of chapterskip.lua--
+
+function matches(i, title)
+ local opt_skip = o.skip
+ if type(o.skip) == 'table' then
+ for i=1, #o.skip do
+ if o.skip[i] and o.skip[i][1] == chapter_state then
+ opt_skip = o.skip[i][2]
+ break
+ end
+ end
+ end
+
+ for category in string.gmatch(opt_skip, " *([^;]*[^; ]) *") do
+ if categories[category:lower()] then
+ if category:lower() == "idx-" or category:lower() == "toggle_idx" then
+ for pattern in string.gmatch(categories[category:lower()], "([^/]+)") do
+ if tonumber(pattern) == i then
+ return true
+ end
+ end
+ else
+ if title then
+ for pattern in string.gmatch(categories[category:lower()], "([^/]+)") do
+ if string.match(title, pattern) then
+ return true
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+local skipped = {}
+local parsed = {}
+
+function prep_chapterskip_var()
+ if chapter_state == 'no-chapters' then return end
+ g_opt_categories = o.categories
+
+ g_opt_skip_once = false
+ if o.skip_once == true or o.skip_once == false then
+ g_opt_skip_once = o.skip_once
+ elseif has_value(o.skip_once, chapter_state) then
+ g_opt_skip_once = true;
+ end
+
+ if type(o.categories) == 'table' then
+ for i=1, #o.categories do
+ if o.categories[i] and o.categories[i][1] == chapter_state then
+ g_opt_categories = o.categories[i][2]
+ break
+ end
+ end
+ end
+
+ for category in string.gmatch(g_opt_categories, "([^;]+)") do
+ local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)")
+ if name then
+ categories[name:lower()] = patterns
+ elseif not parsed[category] then
+ mp.msg.warn("Improper category definition: " .. category)
+ end
+ parsed[category] = true
+ end
+end
+
+function start_chapterskip_countdown(text, duration, osd)
+ g_autoskip_countdown_flag = true
+ g_autoskip_countdown = g_autoskip_countdown - 1
+
+ if o.autoskip_countdown_graceful and (g_autoskip_countdown <= 0) then kill_chapterskip_countdown(); mp.osd_message('',0) return end
+
+ if (g_autoskip_countdown < 0) then kill_chapterskip_countdown(); mp.osd_message('',0) return end
+
+ text = text:gsub("%%countdown%%", g_autoskip_countdown)
+ prompt_msg(text, duration, osd)
+end
+
+function kill_chapterskip_countdown(action)
+ if not g_autoskip_countdown_flag then return end
+ if action == 'osd' then
+ prompt_msg('○ Auto-Skip Cancelled', o.osd_duration, o.autoskip_osd)
+ end
+ if g_autoskip_timer ~= nil then
+ g_autoskip_timer:kill()
+ end
+ unbind_keys(o.cancel_autoskip_countdown_keybind, 'cancel-autoskip-countdown')
+ unbind_keys(o.proceed_autoskip_countdown_keybind, 'proceed-autoskip-countdown')
+ g_autoskip_countdown = o.autoskip_countdown
+ g_autoskip_countdown_flag = false
+end
+
+function chapterskip(_, current, countdown)
+ if g_autoskip_disabled then return end --1.3.4# do not autoskip if g_autoskip_disabled is in disabled state
+ if skip_flag then return end
+ if chapter_state == 'no-chapters' then return end
+ if not autoskip_chapter then return end
+ if not current then return end
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown('osd') end
+ if not countdown then countdown = o.autoskip_countdown end
+
+ local chapters = mp.get_property_native("chapter-list")
+ local skip = false
+ local consecutive_i = 0
+
+ for i=0, #chapters do
+ if (not g_opt_skip_once or not skipped[i]) and i == 0 and chapters[i+1] and matches(i, chapters[i+1].title) then
+ if i == current + 1 or skip == i - 1 then
+ if skip then
+ skipped[skip] = true
+ end
+ skip = i
+ consecutive_i = consecutive_i+1
+ end
+ elseif (not g_opt_skip_once or not skipped[i]) and chapters[i] and matches(i, chapters[i].title) then
+ if i == current + 1 or skip == i - 1 then
+ if skip then
+ skipped[skip] = true
+ end
+ skip = i
+ consecutive_i = consecutive_i+1
+ end
+ elseif skip and countdown <= 0 then
+ if o.autoskip_osd == 'osd-bar' or o.autoskip_osd == 'osd-msg-bar' then prompt_progress('osd-bar', o.osd_duration) end
+
+ if consecutive_i > 1 then
+ local autoskip_osd_string = ''
+ for j=consecutive_i, 1, -1 do
+ local chapter_title = ''
+ if chapters[i-j] then chapter_title = chapters[i-j].title end
+ autoskip_osd_string=(autoskip_osd_string..'\n ➤ Chapter ('..i-j..') '..chapter_title)
+ end
+ prompt_msg('● Auto-Skip'..autoskip_osd_string, o.osd_duration, o.autoskip_osd)
+ else
+ prompt_msg('➤ Auto-Skip: Chapter '.. mp.command_native({'expand-text', '${chapter}'}), o.osd_duration, o.autoskip_osd)
+ end
+ mp.commandv('no-osd', 'set', 'chapter', i-1)
+ skipped[skip] = true
+ return
+ elseif skip and countdown > 0 then
+ g_autoskip_countdown_flag = true
+ bind_keys(o.cancel_autoskip_countdown_keybind, "cancel-autoskip-countdown", function() kill_chapterskip_countdown('osd') return end)
+
+ local autoskip_osd_string = ''
+ local autoskip_graceful_osd = ''
+ if o.autoskip_countdown_graceful then autoskip_graceful_osd = 'Press Keybind to:\n' end
+
+ if consecutive_i > 1 and o.autoskip_countdown_bulk then
+ local autoskip_osd_string = ''
+ for j=consecutive_i, 1, -1 do
+ local chapter_title = ''
+ if chapters[i-j] then chapter_title = chapters[i-j].title end
+ autoskip_osd_string=(autoskip_osd_string..'\n ▷ Chapter ('..i-j..') '..chapter_title)
+ end
+ prompt_msg(autoskip_graceful_osd..'○ Auto-Skip'..' in "'..o.autoskip_countdown..'"'..autoskip_osd_string, 2000, o.autoskip_osd)
+ g_autoskip_timer = mp.add_periodic_timer(1, function ()
+ start_chapterskip_countdown(autoskip_graceful_osd..'○ Auto-Skip'..' in "%countdown%"'..autoskip_osd_string, 2000, o.autoskip_osd)
+ end)
+ else
+ prompt_msg(autoskip_graceful_osd..'▷ Auto-Skip in "'..o.autoskip_countdown..'": Chapter '.. mp.command_native({'expand-text', '${chapter}'}), 2000, o.autoskip_osd)
+ g_autoskip_timer = mp.add_periodic_timer(1, function ()
+ start_chapterskip_countdown(autoskip_graceful_osd..'▷ Auto-Skip in "%countdown%": Chapter '.. mp.command_native({'expand-text', '${chapter}'}), 2000, o.autoskip_osd)
+ end)
+ end
+
+ function proceed_autoskip(force)
+ if not g_autoskip_countdown_flag then kill_chapterskip_countdown() return end
+ if g_autoskip_countdown > 1 and not force then return end
+
+ if o.autoskip_osd == 'osd-bar' or o.autoskip_osd == 'osd-msg-bar' then prompt_progress('osd-bar', o.osd_duration) end
+
+ if consecutive_i > 1 and o.autoskip_countdown_bulk then
+ local autoskip_osd_string = ''
+ for j=consecutive_i, 1, -1 do
+ local chapter_title = ''
+ if chapters[i-j] then chapter_title = chapters[i-j].title end
+ autoskip_osd_string=(autoskip_osd_string..'\n ➤ Chapter ('..i-j..') '..chapter_title)
+ end
+ prompt_msg('● Auto-Skip'..autoskip_osd_string, o.osd_duration, o.autoskip_osd)
+ else
+ prompt_msg('➤ Auto-Skip: Chapter '.. mp.command_native({'expand-text', '${chapter}'}), o.osd_duration, o.autoskip_osd)
+ end
+
+ if consecutive_i > 1 and o.autoskip_countdown_bulk then
+ mp.commandv('no-osd', 'set', 'chapter', i-1)
+ else
+ mp.commandv('no-osd', 'add', 'chapter', 1)
+ end
+ skipped[skip] = true
+ kill_chapterskip_countdown()
+ end
+ bind_keys(o.proceed_autoskip_countdown_keybind, "proceed-autoskip-countdown", function() proceed_autoskip(true) return end)
+ if o.autoskip_countdown_graceful then return end
+ mp.add_timeout(countdown, proceed_autoskip)
+ return
+ end
+ end
+ if skip and countdown <= 0 then
+ if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then
+ return mp.set_property("time-pos", mp.get_property_native("duration"))
+ end
+ mp.commandv("playlist-next")
+ if o.autoskip_osd ~= 'no-osd' or o.autoskip_osd ~= 'osd-bar' then autoskip_playlist_osd = true end
+ elseif skip and countdown > 0 then
+ g_autoskip_countdown_flag = true
+ bind_keys(o.cancel_autoskip_countdown_keybind, "cancel-autoskip-countdown", function() kill_chapterskip_countdown('osd') return end)
+ local autoskip_graceful_osd = ''
+ if o.autoskip_countdown_graceful then autoskip_graceful_osd = 'Press Keybind to:\n' end
+ if consecutive_i > 1 and o.autoskip_countdown_bulk then
+ local i = (mp.get_property_number('chapters')+1 or 0)
+ local autoskip_osd_string = ''
+ for j=consecutive_i, 1, -1 do
+ local chapter_title = ''
+ if chapters[i-j] then chapter_title = chapters[i-j].title end
+ autoskip_osd_string=(autoskip_osd_string..'\n ▷ Chapter ('..i-j..') '..chapter_title)
+ end
+ prompt_msg(autoskip_graceful_osd..'○ Auto-Skip'..' in "'..o.autoskip_countdown..'"'..autoskip_osd_string, 2000, o.autoskip_osd)
+ g_autoskip_timer = mp.add_periodic_timer(1, function ()
+ start_chapterskip_countdown(autoskip_graceful_osd..'○ Auto-Skip'..' in "%countdown%"'..autoskip_osd_string, 2000, o.autoskip_osd)
+ end)
+ else
+ prompt_msg(autoskip_graceful_osd..'▷ Auto-Skip in "'..o.autoskip_countdown..'": Chapter '.. mp.command_native({'expand-text', '${chapter}'}), 2000, o.autoskip_osd)
+ g_autoskip_timer = mp.add_periodic_timer(1, function ()
+ start_chapterskip_countdown(autoskip_graceful_osd..'▷ Auto-Skip in "%countdown%": Chapter '.. mp.command_native({'expand-text', '${chapter}'}), 2000, o.autoskip_osd)
+ end)
+ end
+ function proceed_autoskip(force)
+ if not g_autoskip_countdown_flag then return end
+ if g_autoskip_countdown > 1 and not force then return end
+
+ if o.autoskip_osd == 'osd-bar' or o.autoskip_osd == 'osd-msg-bar' then prompt_progress('osd-bar', o.osd_duration) end
+
+ if consecutive_i > 1 and o.autoskip_countdown_bulk then
+ if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then
+ return mp.set_property("time-pos", mp.get_property_native("duration"))
+ end
+ mp.commandv("playlist-next")
+ else
+ local current_chapter = (mp.get_property_number("chapter") + 1 or 0)
+ local chapters_count = (mp.get_property_number('chapters') or 0)
+
+ if current_chapter == chapters_count then
+ if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then
+ return mp.set_property("time-pos", mp.get_property_native("duration"))
+ end
+ mp.commandv("playlist-next")
+ else
+ mp.commandv('no-osd', 'add', 'chapter', 1)
+ end
+ end
+ if o.autoskip_osd ~= 'no-osd' or o.autoskip_osd ~= 'osd-bar' then autoskip_playlist_osd = true end
+ kill_chapterskip_countdown()
+ end
+ bind_keys(o.proceed_autoskip_countdown_keybind, "proceed-autoskip-countdown", function() proceed_autoskip(true) return end)
+ if o.autoskip_countdown_graceful then return end
+ mp.add_timeout(countdown, proceed_autoskip)
+ end
+end
+
+function toggle_autoskip()
+ if autoskip_chapter == true then
+ prompt_msg('○ Auto-Skip Disabled', o.osd_duration, o.autoskip_osd)
+ autoskip_chapter = false
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown() end
+ elseif autoskip_chapter == false then
+ prompt_msg('● Auto-Skip Enabled', o.osd_duration, o.autoskip_osd)
+ autoskip_chapter = true
+ end
+end
+
+function toggle_category_autoskip()
+ if chapter_state == 'no-chapters' then return end
+ if not mp.get_property_number("chapter") then return end
+ local chapters = mp.get_property_native("chapter-list")
+ local current_chapter = (mp.get_property_number("chapter") + 1 or 0)
+
+ local chapter_title = tostring(current_chapter)
+ if current_chapter > 0 and chapters[current_chapter].title and chapters[current_chapter].title ~= '' then
+ chapter_title = chapters[current_chapter].title
+ end
+
+ local found_i = 0
+ if matches(current_chapter, chapter_title) then
+ for category in string.gmatch(g_opt_categories, "([^;]+)") do
+ local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)")
+
+ for pattern in string.gmatch(patterns, "([^/]+)") do
+ if string.match(chapter_title:lower(), pattern:lower()) then
+ g_opt_categories = g_opt_categories:gsub(esc_string(pattern)..'/?', "")
+ found_i = found_i + 1
+ end
+ end
+ end
+
+ for category in string.gmatch(g_opt_categories, "([^;]+)") do
+ local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)")
+ if name then
+ categories[name:lower()] = patterns
+ elseif not parsed[category] then
+ mp.msg.warn("Improper category definition: " .. category)
+ end
+ parsed[category] = true
+ end
+
+ if type(o.categories) == 'table' then
+ for i=1, #o.categories do
+ if o.categories[i] and o.categories[i][1] == chapter_state then
+ o.categories[i][2] = g_opt_categories
+ break
+ end
+ end
+ else
+ o.categories = g_opt_categories
+ end
+ end
+ if current_chapter > 0 and chapters[current_chapter].title and chapters[current_chapter].title ~= '' then
+ if found_i > 0 or string.match(categories.toggle, esc_string(chapter_title)) then
+ prompt_msg('○ Removed from Auto-Skip\n ▷ Chapter: '..chapter_title, o.osd_duration, o.autoskip_osd)
+ categories.toggle = categories.toggle:gsub(esc_string("^"..chapter_title.."/"), "")
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown() end
+ else
+ prompt_msg('● Added to Auto-Skip\n ➔ Chapter: '..chapter_title, o.osd_duration, o.autoskip_osd)
+ categories.toggle = categories.toggle.."^"..chapter_title.."/"
+ end
+ else
+ if found_i > 0 or string.match(categories.toggle_idx, esc_string(chapter_title)) then
+ prompt_msg('○ Removed from Auto-Skip\n ▷ Chapter: '..chapter_title, o.osd_duration, o.autoskip_osd)
+ categories.toggle_idx = categories.toggle_idx:gsub(esc_string(chapter_title.."/"), "")
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown() end
+ else
+ prompt_msg('● Added to Auto-Skip\n ➔ Chapter: '..chapter_title, o.osd_duration, o.autoskip_osd)
+ categories.toggle_idx = categories.toggle_idx..chapter_title.."/"
+ end
+ end
+end
+
+-- HOOKS --------------------------------------------------------------------
+if user_input_module then mp.add_hook("on_unload", 50, function () input.cancel_user_input() end) end -- chapters.lua
+mp.observe_property("chapter", "number", chapterskip) -- chapterskip.lua
+
+-- smart skip events / properties / hooks --
+
+mp.register_event('file-loaded', function()
+ file_length = (mp.get_property_native('duration') or 0)
+ g_filepath = (mp.get_property('path') or '') --1.3.4# get filepath needed for exclusion_check function
+ if exclusion_check() then g_autoskip_disabled = true end --1.3.4# disable / enable autoskip based on exclusion_check
+
+ if o.playlist_osd and g_playlist_pos > 0 then playlist_osd = true end
+ if playlist_osd and not autoskip_playlist_osd then
+ prompt_msg('['..mp.command_native({'expand-text', '${playlist-pos-1}'})..'/'..mp.command_native({'expand-text', '${playlist-count}'})..'] '..mp.command_native({'expand-text', '${filename}'}))
+ end
+ if autoskip_playlist_osd then
+ prompt_msg('➤ Auto-Skip\n['..mp.command_native({'expand-text', '${playlist-pos-1}'})..'/'..mp.command_native({'expand-text', '${playlist-count}'})..'] '..mp.command_native({'expand-text', '${filename}'}), o.osd_duration, o.autoskip_osd)
+ end
+ playlist_osd = false
+ autoskip_playlist_osd = false
+ force_silence_skip = false
+ skipped = {}
+ initial_chapter_count = mp.get_property_number("chapter-list/count")
+ if initial_chapter_count > 0 and chapter_state ~= 'external-chapters' then chapter_state = 'internal-chapters' end
+ prep_chapterskip_var()
+end)
+
+mp.add_hook("on_load", 50, function()
+ if o.external_chapters_autoload then load_chapters() end
+end)
+
+mp.observe_property('pause', 'bool', function(name, value)
+ if value and skip_flag then
+ restoreProp(initial_skip_time, true)
+ end
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown('osd') end
+end)
+
+mp.add_hook('on_unload', 9, function()
+ if geometry_default == "" then mp.set_property("geometry","") end
+ if o.modified_chapters_autosave == true or has_value(o.modified_chapters_autosave, chapter_state) then write_chapters(false) end
+ mp.set_property("keep-open", keep_open_state)
+ chapter_state = 'no-chapters'
+ g_playlist_pos = (mp.get_property_native('playlist-playing-pos')+1 or 0)
+ kill_chapterskip_countdown()
+end)
+
+mp.register_event('seek', function()
+ if g_autoskip_countdown_flag then kill_chapterskip_countdown('osd') end
+end)
+
+mp.observe_property('eof-reached', 'bool', eofHandler)
+
+-- BINDINGS --------------------------------------------------------------------
+
+bind_keys(o.toggle_autoskip_keybind, "toggle-autoskip", toggle_autoskip)
+bind_keys(o.toggle_category_autoskip_keybind, "toggle-category-autoskip", toggle_category_autoskip)
+bind_keys(o.add_chapter_keybind, "add-chapter", add_chapter)
+bind_keys(o.remove_chapter_keybind, "remove-chapter", remove_chapter)
+bind_keys(o.write_chapters_keybind, "write-chapters", function () write_chapters(true) end)
+bind_keys(o.edit_chapter_keybind, "edit-chapter", edit_chapter)
+bind_keys(o.bake_chapters_keybind, "bake-chapters", bake_chapters)
+bind_keys(o.chapter_prev_keybind, "chapter-prev", function() chapterSeek(-1) end)
+bind_keys(o.chapter_next_keybind, "chapter-next", function() chapterSeek(1) end)
+bind_keys(o.smart_prev_keybind, "smart-prev", smartPrev)
+bind_keys(o.smart_next_keybind, "smart-next", smartNext)
+bind_keys(o.silence_skip_keybind, "silence-skip", silenceSkip)
diff --git a/mpv/scripts/file-browser/main.lua b/mpv/scripts/file-browser/main.lua
new file mode 100644
index 0000000..739badd
--- /dev/null
+++ b/mpv/scripts/file-browser/main.lua
@@ -0,0 +1,76 @@
+--[[
+ mpv-file-browser
+
+ 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.
+
+ For full documentation see: https://github.com/CogentRedTester/mpv-file-browser
+]]--
+
+local mp = require 'mp'
+
+local o = require 'modules.options'
+
+-- setting the package paths
+package.path = mp.command_native({"expand-path", o.module_directory}).."/?.lua;"..package.path
+
+local addons = require 'modules.addons'
+local keybinds = require 'modules.keybinds'
+local setup = require 'modules.setup'
+local controls = require 'modules.controls'
+local observers = require 'modules.observers'
+local script_messages = require 'modules.script-messages'
+
+local input_loaded, input = pcall(require, "mp.input")
+local user_input_loaded, user_input = pcall(require, "user-input-module")
+
+
+-- root and addon setup
+setup.root()
+addons.load_internal_addons()
+if o.addons then addons.load_external_addons() end
+addons.setup_addons()
+
+--these need to be below the addon setup in case any parsers add custom entries
+setup.extensions_list()
+keybinds.setup_keybinds()
+
+-- property observers
+mp.observe_property('path', 'string', observers.current_directory)
+if o.map_dvd_device then mp.observe_property('dvd-device', 'string', observers.dvd_device) end
+if o.map_bd_device then mp.observe_property('bluray-device', 'string', observers.bd_device) end
+if o.map_cdda_device then mp.observe_property('cdda-device', 'string', observers.cd_device) end
+if o.align_x == 'auto' then mp.observe_property('osd-align-x', 'string', observers.osd_align) end
+if o.align_y == 'auto' then mp.observe_property('osd-align-y', 'string', observers.osd_align) end
+
+-- scripts messages
+mp.register_script_message('=>', script_messages.chain)
+mp.register_script_message('delay-command', script_messages.delay_command)
+mp.register_script_message('conditional-command', script_messages.conditional_command)
+mp.register_script_message('evaluate-expressions', script_messages.evaluate_expressions)
+mp.register_script_message('run-statement', script_messages.run_statement)
+
+mp.register_script_message('browse-directory', controls.browse_directory)
+mp.register_script_message("get-directory-contents", script_messages.get_directory_contents)
+
+--declares the keybind to open the browser
+mp.add_key_binding('MENU','browse-files', controls.toggle)
+mp.add_key_binding('Ctrl+o','open-browser', controls.open)
+
+if input_loaded then
+ mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
+ input.get({
+ prompt = "open directory:",
+ id = "file-browser/browse-directory",
+ submit = function(text)
+ controls.browse_directory(text)
+ input.terminate()
+ end
+ })
+ end)
+elseif user_input_loaded then
+ mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
+ user_input.get_user_input(controls.browse_directory, {request_text = "open directory:"})
+ end)
+end
diff --git a/mpv/scripts/file-browser/modules/addons.lua b/mpv/scripts/file-browser/modules/addons.lua
new file mode 100644
index 0000000..11aac08
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons.lua
@@ -0,0 +1,204 @@
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local parser_API = require 'modules.apis.parser'
+
+local API_MAJOR, API_MINOR, API_PATCH = g.API_VERSION:match("(%d+)%.(%d+)%.(%d+)")
+API_MAJOR, API_MINOR, API_PATCH = tonumber(API_MAJOR), tonumber(API_MINOR), tonumber(API_PATCH)
+
+---checks if the given parser has a valid version number
+---@param parser Parser|Keybind
+---@param id string
+---@return boolean?
+local function check_api_version(parser, id)
+ if parser.version then
+ msg.warn(('%s: use of the `version` field is deprecated - use `api_version` instead'):format(id))
+ parser.api_version = parser.version
+ end
+
+ local version = parser.api_version
+ if type(version) ~= 'string' then return msg.error(("%s: field `api_version` must be a string, got %s"):format(id, tostring(version))) end
+
+ local major, minor = version:match("(%d+)%.(%d+)")
+ major, minor = tonumber(major), tonumber(minor)
+
+ if not major or not minor then
+ return msg.error(("%s: invalid version number, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version))
+ elseif major ~= API_MAJOR then
+ return msg.error(("%s has wrong major version number, expected v%d.x.x, got, v%s"):format(id, API_MAJOR, version))
+ elseif minor > API_MINOR then
+ 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))
+ end
+ return true
+end
+
+---create a unique id for the given parser
+---@param parser Parser
+local function set_parser_id(parser)
+ local name = parser.name
+ if g.parsers[name] then
+ local n = 2
+ name = parser.name.."_"..n
+ while g.parsers[name] do
+ n = n + 1
+ name = parser.name.."_"..n
+ end
+ end
+
+ g.parsers[name] = parser
+ g.parsers[parser] = { id = name }
+end
+
+---runs an addon in a separate environment
+---@param path string
+---@return unknown
+local function run_addon(path)
+ local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$"))
+ local addon_environment = fb_utils.redirect_table(_G)
+ addon_environment._G = addon_environment ---@diagnostic disable-line inject-field
+
+ --gives each addon custom debug messages
+ addon_environment.package = fb_utils.redirect_table(addon_environment.package) ---@diagnostic disable-line inject-field
+ addon_environment.package.loaded = fb_utils.redirect_table(addon_environment.package.loaded)
+ local msg_module = {
+ log = function(level, ...) msg.log(level, name_sqbr, ...) end,
+ fatal = function(...) return msg.fatal(name_sqbr, ...) end,
+ error = function(...) return msg.error(name_sqbr, ...) end,
+ warn = function(...) return msg.warn(name_sqbr, ...) end,
+ info = function(...) return msg.info(name_sqbr, ...) end,
+ verbose = function(...) return msg.verbose(name_sqbr, ...) end,
+ debug = function(...) return msg.debug(name_sqbr, ...) end,
+ trace = function(...) return msg.trace(name_sqbr, ...) end,
+ }
+ addon_environment.print = msg_module.info ---@diagnostic disable-line inject-field
+
+ addon_environment.require = function(module) ---@diagnostic disable-line inject-field
+ if module == "mp.msg" then return msg_module end
+ return require(module)
+ end
+
+ ---@type function?, string?
+ local chunk, err
+ if setfenv then ---@diagnostic disable-line deprecated
+ --since I stupidly named a function loadfile I need to specify the global one
+ --I've been using the name too long to want to change it now
+ chunk, err = _G.loadfile(path)
+ if not chunk then return msg.error(err) end
+ setfenv(chunk, addon_environment) ---@diagnostic disable-line deprecated
+ else
+ chunk, err = _G.loadfile(path, "bt", addon_environment) ---@diagnostic disable-line redundant-parameter
+ if not chunk then return msg.error(err) end
+ end
+
+ ---@diagnostic disable-next-line no-unknown
+ local success, result = xpcall(chunk, fb_utils.traceback)
+ return success and result or nil
+end
+
+---Setup an internal or external parser.
+---Note that we're somewhat bypassing the type system here as we're converting from a
+---ParserConfig object to a Parser object. As such we need to make sure that the
+---we're doing everything correctly. A 2.0 release of the addon API could simplify
+---this by formally separating ParserConfigs from Parsers and providing an
+---API to register parsers.
+---@param parser ParserConfig
+---@param file string
+---@return nil
+local function setup_parser(parser, file)
+ parser = setmetatable(parser, { __index = parser_API }) --[[@as Parser]]
+ parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "")
+
+ set_parser_id(parser)
+ if not check_api_version(parser, file) then return msg.error("aborting load of parser", parser:get_id(), "from", file) end
+
+ msg.verbose("imported parser", parser:get_id(), "from", file)
+
+ --sets missing functions
+ if not parser.can_parse then
+ if parser.parse then parser.can_parse = function() return true end
+ else parser.can_parse = function() return false end end
+ end
+
+ if parser.priority == nil then parser.priority = 0 end
+ if type(parser.priority) ~= "number" then return msg.error("parser", parser:get_id(), "needs a numeric priority") end
+
+ table.insert(g.parsers, parser)
+end
+
+---load an external addon
+---@param file string
+---@param path string
+---@return nil
+local function setup_addon(file, path)
+ if file:sub(-4) ~= ".lua" then return msg.verbose(path, "is not a lua file - aborting addon setup") end
+
+ local addon_parsers = run_addon(path) --[=[@as ParserConfig|ParserConfig[]]=]
+ if addon_parsers and not next(addon_parsers) then return msg.verbose('addon', path, 'returned empry table - special case, ignoring') end
+ if not addon_parsers or type(addon_parsers) ~= "table" then return msg.error("addon", path, "did not return a table") end
+
+ --if the table contains a priority key then we assume it isn't an array of parsers
+ if not addon_parsers[1] then addon_parsers = {addon_parsers} end
+
+ for _, parser in ipairs(addon_parsers --[=[@as ParserConfig[]]=]) do
+ setup_parser(parser, file)
+ end
+end
+
+---loading external addons
+---@param directory string
+---@return nil
+local function load_addons(directory)
+ directory = fb_utils.fix_path(directory, true)
+
+ local files = utils.readdir(directory)
+ if not files then return msg.verbose('not loading external addons - could not read', o.addon_directory) end
+
+ for _, file in ipairs(files) do
+ setup_addon(file, directory..file)
+ end
+end
+
+local function load_internal_addons()
+ local script_dir = mp.get_script_directory()
+ if not script_dir then return msg.error('script is not being run as a directory script!') end
+ local internal_addon_dir = script_dir..'/modules/addons/'
+ load_addons(internal_addon_dir)
+end
+
+local function load_external_addons()
+ local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'}) --[[@as string|nil]]
+ if not addon_dir then return msg.verbose('not loading external addons - could not resolve', o.addon_directory) end
+ load_addons(addon_dir)
+end
+
+---Orders the addons by priority, sets the parser index values,
+---and runs the setup methods of the addons.
+local function setup_addons()
+ table.sort(g.parsers, function(a, b) return a.priority < b.priority end)
+
+ --we want to store the indexes of the parsers
+ for i = #g.parsers, 1, -1 do g.parsers[ g.parsers[i] ].index = i end
+
+ --we want to run the setup functions for each addon
+ for index, parser in ipairs(g.parsers) do
+ if parser.setup then
+ local success = xpcall(function() parser:setup() end, fb_utils.traceback)
+ if not success then
+ msg.error("parser", parser:get_id(), "threw an error in the setup method - removing from list of parsers")
+ table.remove(g.parsers, index)
+ end
+ end
+ end
+end
+
+---@class addons
+return {
+ check_api_version = check_api_version,
+ load_internal_addons = load_internal_addons,
+ load_external_addons = load_external_addons,
+ setup_addons = setup_addons,
+}
diff --git a/mpv/scripts/file-browser/modules/addons/cache.lua b/mpv/scripts/file-browser/modules/addons/cache.lua
new file mode 100644
index 0000000..b1011fd
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/cache.lua
@@ -0,0 +1,146 @@
+--[[
+ This file is an internal file-browser addon.
+ It should not be imported like a normal module.
+
+ Maintains a cache of the accessed directories to improve
+ parsing speed. Disabled by default.
+]]
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local fb = require 'file-browser'
+
+---@type ParserConfig
+local cacheParser = {
+ name = 'cache',
+ priority = 0,
+ api_version = '1.9',
+}
+
+---@class CacheEntry
+---@field list List
+---@field opts Opts?
+---@field timeout MPTimer
+
+---@type table<string,CacheEntry>
+local cache = {}
+
+---@type table<string,(async fun(list: List?, opts: Opts?))[]>
+local pending_parses = {}
+
+---@param directories? string[]
+local function clear_cache(directories)
+ if directories then
+ msg.debug('clearing cache for', #directories, 'directorie(s)')
+ for _, dir in ipairs(directories) do
+ if cache[dir] then
+ msg.trace('clearing cache for', dir)
+ cache[dir].timeout:kill()
+ cache[dir] = nil
+ end
+ end
+ else
+ msg.debug('clearing cache')
+ for _, entry in pairs(cache) do
+ entry.timeout:kill()
+ end
+ cache = {}
+ end
+end
+
+---@type string
+local prev_directory = ''
+
+function cacheParser:can_parse(directory, parse_state)
+ -- allows the cache to be forcibly used or bypassed with the
+ -- cache/use parse property.
+ if parse_state.properties.cache and parse_state.properties.cache.use ~= nil then
+ if parse_state.source == 'browser' then prev_directory = directory end
+ return parse_state.properties.cache.use
+ end
+
+ -- the script message is guaranteed to always bypass the cache
+ if parse_state.source == 'script-message' then return false end
+ if not fb.get_opt('cache') or directory == '' then return false end
+
+ -- clear the cache if reloading the current directory in the browser
+ -- this means that fb.rescan() should maintain expected behaviour
+ if parse_state.source == 'browser' then
+ if prev_directory == directory then clear_cache({directory}) end
+ prev_directory = directory
+ end
+
+ return true
+end
+
+---@async
+function cacheParser:parse(directory)
+ if cache[directory] then
+ msg.verbose('fetching', directory, 'contents from cache')
+ cache[directory].timeout:kill()
+ cache[directory].timeout:resume()
+ return cache[directory].list, cache[directory].opts
+ end
+
+ ---@type List?, Opts?
+ local list, opts
+
+ -- if another parse is already running on the same directory, then wait and use the same result
+ if not pending_parses[directory] then
+ pending_parses[directory] = {}
+ list, opts = self:defer(directory)
+ else
+ msg.debug('parse for', directory, 'already running - waiting for other parse to finish...')
+ table.insert(pending_parses[directory], fb.coroutine.callback(30))
+ list, opts = coroutine.yield()
+ end
+
+ local pending = pending_parses[directory]
+ -- need to clear the pending parses before resuming them or they will also attempt to resume the parses
+ pending_parses[directory] = nil
+ if pending and #pending > 0 then
+ msg.debug('resuming', #pending, 'pending parses for', directory)
+ for _, cb in ipairs(pending) do
+ cb(list, opts)
+ end
+ end
+
+ if not list then return end
+
+ -- pending will be truthy for the original parse and falsy for any parses that were pending
+ if pending then
+ msg.debug('storing', directory, 'contents in cache')
+ cache[directory] = {
+ list = list,
+ opts = opts,
+ timeout = mp.add_timeout(120, function() cache[directory] = nil end),
+ }
+ end
+
+ return list, opts
+end
+
+cacheParser.keybinds = {
+ {
+ key = 'Ctrl+Shift+r',
+ name = 'clear',
+ command = function() clear_cache() ; fb.rescan() end,
+ }
+}
+
+-- provide method of clearing the cache through script messages
+mp.register_script_message('cache/clear', function(dirs)
+ if not dirs then
+ return clear_cache()
+ end
+
+ ---@type string[]?
+ local directories = utils.parse_json(dirs)
+ if not directories then msg.error('unable to parse', dirs) end
+
+ clear_cache(directories)
+end)
+
+return cacheParser
diff --git a/mpv/scripts/file-browser/modules/addons/file.lua b/mpv/scripts/file-browser/modules/addons/file.lua
new file mode 100644
index 0000000..a57573c
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/file.lua
@@ -0,0 +1,46 @@
+-- This file is an internal file-browser addon.
+-- It should not be imported like a normal module.
+
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+---Parser for native filesystems
+---@type ParserConfig
+local file_parser = {
+ name = "file",
+ priority = 110,
+ api_version = '1.0.0',
+}
+
+--try to parse any directory except for the root
+function file_parser:can_parse(directory)
+ return directory ~= ''
+end
+
+--scans the given directory using the mp.utils.readdir function
+function file_parser:parse(directory)
+ local new_list = {}
+ local list1 = utils.readdir(directory, 'dirs')
+ if list1 == nil then return nil end
+
+ --sorts folders and formats them into the list of directories
+ for i=1, #list1 do
+ local item = list1[i]
+
+ msg.trace(item..'/')
+ table.insert(new_list, {name = item..'/', type = 'dir'})
+ end
+
+ --appends files to the list of directory items
+ local list2 = utils.readdir(directory, 'files')
+ if list2 == nil then return nil end
+ for i=1, #list2 do
+ local item = list2[i]
+
+ msg.trace(item)
+ table.insert(new_list, {name = item, type = 'file'})
+ end
+ return new_list
+end
+
+return file_parser
diff --git a/mpv/scripts/file-browser/modules/addons/find.lua b/mpv/scripts/file-browser/modules/addons/find.lua
new file mode 100644
index 0000000..0b64d92
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/find.lua
@@ -0,0 +1,124 @@
+--[[
+ This file is an internal file-browser addon.
+ It should not be imported like a normal module.
+
+ Allows searching the current directory.
+]]--
+
+local msg = require "mp.msg"
+local fb = require "file-browser"
+local input_loaded, input = pcall(require, "mp.input")
+local user_input_loaded, user_input = pcall(require, "user-input-module")
+
+---@type ParserConfig
+local find = {
+ api_version = "1.3.0"
+}
+
+---@type thread|nil
+local latest_coroutine = nil
+
+---@type State
+local global_fb_state = getmetatable(fb.get_state()).__original
+
+---@param name string
+---@param query string
+---@return boolean
+local function compare(name, query)
+ if name:find(query) then return true end
+ if name:lower():find(query) then return true end
+ if name:upper():find(query) then return true end
+
+ return false
+end
+
+---@async
+---@param key Keybind
+---@param state State
+---@param co thread
+---@return boolean?
+local function main(key, state, co)
+ if not state.list then return false end
+
+ ---@type string
+ local text
+ if key.name == "find/find" then text = "Find: enter search string"
+ else text = "Find: enter advanced search string" end
+
+ if input_loaded then
+ input.get({
+ prompt = text .. "\n>",
+ id = "file-browser/find",
+ submit = fb.coroutine.callback(),
+ })
+ elseif user_input_loaded then
+ user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } )
+ end
+
+ local query, error = coroutine.yield()
+ if input_loaded then input.terminate() end
+ if not query then return msg.debug(error) end
+
+ -- allow the directory to be changed before this point
+ local list = fb.get_list()
+ local parse_id = global_fb_state.co
+
+ if key.name == "find/find" then
+ query = fb.pattern_escape(query)
+ end
+
+ local results = {}
+
+ for index, item in ipairs(list) do
+ if compare(item.label or item.name, query) then
+ table.insert(results, index)
+ end
+ end
+
+ if (#results < 1) then
+ msg.warn("No matching items for '"..query.."'")
+ return
+ end
+
+ --keep cycling through the search results if any are found
+ --putting this into a separate coroutine removes any passthrough ambiguity
+ --the final return statement should return to `step_find` not any other function
+ ---@async
+ fb.coroutine.run(function()
+ latest_coroutine = coroutine.running()
+ ---@type number
+ local rindex = 1
+ while (true) do
+
+ if rindex == 0 then rindex = #results
+ elseif rindex == #results + 1 then rindex = 1 end
+
+ fb.set_selected_index(results[rindex])
+ local direction = coroutine.yield(true) --[[@as number]]
+ rindex = rindex + direction
+
+ if parse_id ~= global_fb_state.co then
+ latest_coroutine = nil
+ return
+ end
+ end
+ end)
+end
+
+local function step_find(key)
+ if not latest_coroutine then return false end
+ ---@type number
+ local direction = 0
+ if key.name == "find/next" then direction = 1
+ elseif key.name == "find/prev" then direction = -1 end
+ return fb.coroutine.resume_err(latest_coroutine, direction)
+end
+
+find.keybinds = {
+ {"Ctrl+f", "find", main, {}},
+ {"Ctrl+F", "find_advanced", main, {}},
+ {"n", "next", step_find, {}},
+ {"N", "prev", step_find, {}},
+}
+
+return find
diff --git a/mpv/scripts/file-browser/modules/addons/home-label.lua b/mpv/scripts/file-browser/modules/addons/home-label.lua
new file mode 100644
index 0000000..5e6cf8e
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/home-label.lua
@@ -0,0 +1,31 @@
+--[[
+ An addon for mpv-file-browser which displays ~/ for the home directory instead of the full path
+]]--
+
+local mp = require "mp"
+local fb = require "file-browser"
+
+local home = fb.fix_path(mp.command_native({"expand-path", "~/"}) --[[@as string]], true)
+
+---@type ParserConfig
+local home_label = {
+ priority = 100,
+ api_version = "1.0.0"
+}
+
+function home_label:can_parse(directory)
+ if not fb.get_opt('home_label') then return false end
+ return directory:sub(1, home:len()) == home
+end
+
+---@async
+function home_label:parse(directory)
+ local list, opts = self:defer(directory)
+ if not opts then opts = {} end
+ if (not opts.directory or opts.directory == directory) and not opts.directory_label then
+ opts.directory_label = "~/"..(directory:sub(home:len()+1) or "")
+ end
+ return list, opts
+end
+
+return home_label \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua b/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua
new file mode 100644
index 0000000..c68f408
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/last-opened-directory.lua
@@ -0,0 +1,62 @@
+--[[
+ An addon for mpv-file-browser which stores the last opened directory and
+ sets it as the opened directory the next time mpv is opened.
+
+ Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
+]]--
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+
+local fb = require 'file-browser'
+
+local state_file = mp.command_native({'expand-path', fb.get_opt('last_opened_directory_file')}) --[[@as string]]
+msg.verbose('using', state_file)
+
+---@param directory? string
+---@return nil
+local function write_directory(directory)
+ if not fb.get_opt('save_last_opened_directory') then return end
+
+ local file = io.open(state_file, 'w+')
+
+ if not file then return msg.error('could not open', state_file, 'for writing') end
+
+ directory = directory or fb.get_directory() or ''
+ msg.verbose('writing', directory, 'to', state_file)
+ file:write(directory)
+ file:close()
+end
+
+---@type ParserConfig
+local addon = {
+ api_version = '1.7.0',
+ priority = 0,
+}
+
+function addon:setup()
+ if not fb.get_opt('default_to_last_opened_directory') then return end
+
+ local file = io.open(state_file, "r")
+ if not file then
+ return msg.info('failed to open', state_file, 'for reading (may be due to first load)')
+ end
+
+ local dir = file:read("*a")
+ msg.verbose('setting default directory to', dir)
+ fb.browse_directory(dir, false)
+ file:close()
+end
+
+function addon:can_parse(dir, parse_state)
+ if parse_state.source == 'browser' then write_directory(dir) end
+ return false
+end
+
+function addon:parse()
+ return nil
+end
+
+mp.register_event('shutdown', function() write_directory() end)
+
+return addon
diff --git a/mpv/scripts/file-browser/modules/addons/ls.lua b/mpv/scripts/file-browser/modules/addons/ls.lua
new file mode 100644
index 0000000..8c2e23c
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/ls.lua
@@ -0,0 +1,68 @@
+--[[
+ An addon for mpv-file-browser which uses the Linux ls command to parse native directories
+ This behaves near identically to the native parser, but IO is done asynchronously.
+
+ Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
+]]--
+
+local mp = require "mp"
+local msg = require "mp.msg"
+local fb = require "file-browser"
+
+local PLATFORM = fb.get_platform()
+
+---@type ParserConfig
+local ls = {
+ priority = 109,
+ api_version = "1.9.0",
+ name = "ls",
+ keybind_name = "file"
+}
+
+---@async
+---@param args string[]
+---@param parse_state ParseState
+---@return string|nil
+local function command(args, parse_state)
+ local async = mp.command_native_async({
+ name = "subprocess",
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ args = args
+ }, fb.coroutine.callback(30))
+
+ ---@type boolean, boolean, MPVSubprocessResult
+ local completed, _, cmd = parse_state:yield()
+ if not completed then
+ msg.warn('read timed out for:', table.unpack(args))
+ mp.abort_async_command(async)
+ return nil
+ end
+
+ return cmd.status == 0 and cmd.stdout or nil
+end
+
+function ls:can_parse(directory)
+ if not fb.get_opt('ls_parser') then return false end
+ return PLATFORM ~= 'windows' and directory ~= '' and not fb.get_protocol(directory)
+end
+
+---@async
+function ls:parse(directory, parse_state)
+ local list = {}
+ local files = command({"ls", "-1", "-p", "-A", "-N", "--zero", "-L", directory}, parse_state)
+
+ if not files then return nil end
+
+ for str in files:gmatch("%Z+") do
+ local is_dir = str:sub(-1) == "/"
+ msg.trace(str)
+
+ table.insert(list, {name = str, type = is_dir and "dir" or "file"})
+ end
+
+ return list
+end
+
+return ls
diff --git a/mpv/scripts/file-browser/modules/addons/root.lua b/mpv/scripts/file-browser/modules/addons/root.lua
new file mode 100644
index 0000000..412a5ba
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/root.lua
@@ -0,0 +1,26 @@
+-- This file is an internal file-browser addon.
+-- It should not be imported like a normal module.
+
+local g = require 'modules.globals'
+
+---Parser for the root.
+---@type ParserConfig
+local root_parser = {
+ name = "root",
+ priority = math.huge,
+ api_version = '1.0.0',
+}
+
+function root_parser:can_parse(directory)
+ return directory == ''
+end
+
+--we return the root directory exactly as setup
+function root_parser:parse()
+ return g.root, {
+ sorted = true,
+ filtered = true,
+ }
+end
+
+return root_parser
diff --git a/mpv/scripts/file-browser/modules/addons/windir.lua b/mpv/scripts/file-browser/modules/addons/windir.lua
new file mode 100644
index 0000000..436fe33
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/windir.lua
@@ -0,0 +1,218 @@
+--[[
+ An addon for mpv-file-browser which uses the Windows dir command to parse native directories
+ This behaves near identically to the native parser, but IO is done asynchronously.
+
+ Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
+]]--
+
+local mp = require "mp"
+local msg = require "mp.msg"
+local fb = require "file-browser"
+
+local PLATFORM = fb.get_platform()
+
+---@param bytes string
+---@return fun(): number, number
+local function byte_iterator(bytes)
+ ---@async
+ ---@return number?
+ local function iter()
+ for i = 1, #bytes do
+ coroutine.yield(bytes:byte(i), i)
+ end
+ error('malformed utf16le string - expected byte but found end of string')
+ end
+
+ return coroutine.wrap(iter)
+end
+
+---@param bits number
+---@param by number
+---@return number
+local function lshift(bits, by)
+ return bits * 2^by
+end
+
+---@param bits number
+---@param by number
+---@return integer
+local function rshift(bits, by)
+ return math.floor(bits / 2^by)
+end
+
+---@param bits number
+---@param i number
+---@return number
+local function bits_below(bits, i)
+ return bits % 2^i
+end
+
+---@param bits number
+---@param i number exclusive
+---@param j number inclusive
+---@return integer
+local function bits_between(bits, i, j)
+ return rshift(bits_below(bits, j), i)
+end
+
+---@param bytes string
+---@return number[]
+local function utf16le_to_unicode(bytes)
+ msg.trace('converting from utf16-le to unicode codepoints')
+
+ ---@type number[]
+ local codepoints = {}
+
+ local get_byte = byte_iterator(bytes)
+
+ while true do
+ -- start of a char
+ local success, little, i = pcall(get_byte)
+ if not success then break end
+
+ local big = get_byte()
+ local codepoint = little + lshift(big, 8)
+
+ if codepoint < 0xd800 or codepoint > 0xdfff then
+ table.insert(codepoints, codepoint)
+ else
+ -- handling surrogate pairs
+ -- grab the next two bytes to grab the low surrogate
+ local high_pair = codepoint
+ local low_pair = get_byte() + lshift(get_byte(), 8)
+
+ if high_pair >= 0xdc00 then
+ error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair))
+ elseif low_pair < 0xdc00 then
+ error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair))
+ end
+
+ -- The last 10 bits of each surrogate are the two halves of the codepoint
+ -- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
+ local high_bits = bits_below(high_pair, 10)
+ local low_bits = bits_below(low_pair, 10)
+ local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000
+
+ table.insert(codepoints, surrogate_par)
+ end
+ end
+
+ return codepoints
+end
+
+---@param codepoints number[]
+---@return string
+local function unicode_to_utf8(codepoints)
+ ---@type number[]
+ local bytes = {}
+
+ -- https://en.wikipedia.org/wiki/UTF-8#Description
+ for i, codepoint in ipairs(codepoints) do
+ if codepoint >= 0xd800 and codepoint <= 0xdfff then
+ error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint))
+ elseif codepoint <= 0x7f then
+ table.insert(bytes, codepoint)
+ elseif codepoint <= 0x7ff then
+ table.insert(bytes, 0xC0 + rshift(codepoint, 6))
+ table.insert(bytes, 0x80 + bits_below(codepoint, 6))
+ elseif codepoint <= 0xffff then
+ table.insert(bytes, 0xE0 + rshift(codepoint, 12))
+ table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
+ table.insert(bytes, 0x80 + bits_below(codepoint, 6))
+ elseif codepoint <= 0x10ffff then
+ table.insert(bytes, 0xF0 + rshift(codepoint, 18))
+ table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18))
+ table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
+ table.insert(bytes, 0x80 + bits_below(codepoint, 6))
+ else
+ error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint))
+ end
+ end
+
+ return string.char(table.unpack(bytes))
+end
+
+local function utf8(text)
+ return unicode_to_utf8(utf16le_to_unicode(text))
+end
+
+---@type ParserConfig
+local dir = {
+ priority = 109,
+ api_version = "1.9.0",
+ name = "cmd-dir",
+ keybind_name = "file"
+}
+
+---@async
+---@param args string[]
+---@param parse_state ParseState
+---@return string|nil
+local function command(args, parse_state)
+ local async = mp.command_native_async({
+ name = "subprocess",
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ args = args,
+ }, fb.coroutine.callback(30) )
+
+ ---@type boolean, boolean, MPVSubprocessResult
+ local completed, _, cmd = parse_state:yield()
+ if not completed then
+ msg.warn('read timed out for:', table.unpack(args))
+ mp.abort_async_command(async)
+ return nil
+ end
+
+ local success = xpcall(function()
+ cmd.stdout = utf8(cmd.stdout) or ''
+ cmd.stderr = utf8(cmd.stderr) or ''
+ end, fb.traceback)
+
+ if not success then return msg.error('failed to convert utf16-le string to utf8') end
+
+ --dir returns this exact error message if the directory is empty
+ if cmd.status == 1 and cmd.stderr == "File Not Found\r\n" then cmd.status = 0 end
+ if cmd.status ~= 0 then return msg.error(cmd.stderr) end
+
+ return cmd.status == 0 and cmd.stdout or nil
+end
+
+function dir:can_parse(directory)
+ if not fb.get_opt('windir_parser') then return false end
+ return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory)
+end
+
+---@async
+function dir:parse(directory, parse_state)
+ local list = {}
+
+ -- the dir command expects backslashes for our paths
+ directory = string.gsub(directory, "/", "\\")
+
+ local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state)
+ if not dirs then return end
+
+ local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state)
+ if not files then return end
+
+ for name in dirs:gmatch("[^\n\r]+") do
+ name = name.."/"
+ if fb.valid_dir(name) then
+ table.insert(list, { name = name, type = "dir" })
+ msg.trace(name)
+ end
+ end
+
+ for name in files:gmatch("[^\n\r]+") do
+ if fb.valid_file(name) then
+ table.insert(list, { name = name, type = "file" })
+ msg.trace(name)
+ end
+ end
+
+ return list, { filtered = true }
+end
+
+return dir
diff --git a/mpv/scripts/file-browser/modules/addons/winroot.lua b/mpv/scripts/file-browser/modules/addons/winroot.lua
new file mode 100644
index 0000000..0b046e8
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/addons/winroot.lua
@@ -0,0 +1,62 @@
+--[[
+ This file is an internal file-browser addon.
+ It should not be imported like a normal module.
+
+ Automatically populates the root with windows drives on startup.
+ Ctrl+r will add new drives mounted since startup.
+
+ Drives will only be added if they are not already present in the root.
+]]
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local fb = require 'file-browser'
+
+local PLATFORM = fb.get_platform()
+
+---returns a list of windows drives
+---@return string[]?
+local function get_drives()
+ ---@type MPVSubprocessResult?, string?
+ local result, err = mp.command_native({
+ name = 'subprocess',
+ playback_only = false,
+ capture_stdout = true,
+ args = {'fsutil', 'fsinfo', 'drives'}
+ })
+ if not result then return msg.error(err) end
+ if result.status ~= 0 then return msg.error('could not read windows root') end
+
+ local root = {}
+ for drive in result.stdout:gmatch("(%a:)\\") do
+ table.insert(root, drive..'/')
+ end
+ return root
+end
+
+-- adds windows drives to the root if they are not already present
+local function import_drives()
+ if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end
+
+ local drives = get_drives()
+ if not drives then return end
+
+ for _, drive in ipairs(drives) do
+ fb.register_root_item(drive)
+ end
+end
+
+local keybind = {
+ key = 'Ctrl+r',
+ name = 'import_root_drives',
+ command = import_drives,
+ parser = 'root',
+ passthrough = true
+}
+
+---@type ParserConfig
+return {
+ api_version = '1.9.0',
+ setup = import_drives,
+ keybinds = { keybind }
+}
diff --git a/mpv/scripts/file-browser/modules/apis/fb.lua b/mpv/scripts/file-browser/modules/apis/fb.lua
new file mode 100644
index 0000000..a7f5b35
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/apis/fb.lua
@@ -0,0 +1,198 @@
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local ass = require 'modules.ass'
+local directory_movement = require 'modules.navigation.directory-movement'
+local scanning = require 'modules.navigation.scanning'
+local controls = require 'modules.controls'
+
+---@class FbAPI: fb_utils
+local fb = setmetatable({}, { __index = setmetatable({}, { __index = fb_utils }) })
+package.loaded["file-browser"] = setmetatable({}, { __index = fb })
+
+--these functions we'll provide as-is
+fb.redraw = ass.update_ass
+fb.browse_directory = controls.browse_directory
+
+---Clears the directory cache.
+---@return thread
+function fb.rescan()
+ return scanning.rescan()
+end
+
+---@async
+---@return thread
+function fb.rescan_await()
+ local co = scanning.rescan(nil, fb_utils.coroutine.callback())
+ coroutine.yield()
+ return co
+end
+
+---@param directories? string[]
+function fb.clear_cache(directories)
+ if directories then
+ mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear', utils.format_json(directories))
+ else
+ mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear')
+ end
+end
+
+---A wrapper around scan_directory for addon API.
+---@async
+---@param directory string
+---@param parse_state ParseStateTemplate
+---@return Item[]|nil
+---@return Opts
+function fb.parse_directory(directory, parse_state)
+ if not parse_state then parse_state = { source = "addon" }
+ elseif not parse_state.source then parse_state.source = "addon" end
+ return scanning.scan_directory(directory, parse_state)
+end
+
+---Register file extensions which can be opened by the browser.
+---@param ext string
+function fb.register_parseable_extension(ext)
+ g.parseable_extensions[string.lower(ext)] = true
+end
+
+---Deregister file extensions which can be opened by the browser.
+---@param ext string
+function fb.remove_parseable_extension(ext)
+ g.parseable_extensions[string.lower(ext)] = nil
+end
+
+---Add a compatible extension to show through the filter, only applies if run during the setup() method.
+---@param ext string
+function fb.add_default_extension(ext)
+ table.insert(g.compatible_file_extensions, ext)
+end
+
+---Add item to root at position pos.
+---@param item Item
+---@param pos? number
+function fb.insert_root_item(item, pos)
+ msg.debug("adding item to root", item.label or item.name, pos)
+ item.ass = item.ass or fb.ass_escape(item.label or item.name)
+ item.type = "dir"
+ table.insert(g.root, pos or (#g.root + 1), item)
+end
+
+---Add a new mapping to the given directory.
+---@param directory string
+---@param mapping string
+---@param pattern? boolean
+---@return string
+function fb.register_directory_mapping(directory, mapping, pattern)
+ if not pattern then mapping = '^'..fb_utils.pattern_escape(mapping) end
+ g.directory_mappings[mapping] = directory
+ msg.verbose('registering directory alias', mapping, directory)
+
+ directory_movement.set_current_file(g.current_file.original_path)
+ return mapping
+end
+
+---Remove all directory mappings that map to the given directory.
+---@param directory string
+---@return string[]
+function fb.remove_all_mappings(directory)
+ local removed = {}
+ for mapping, target in pairs(g.directory_mappings) do
+ if target == directory then
+ g.directory_mappings[mapping] = nil
+ table.insert(removed, mapping)
+ end
+ end
+ return removed
+end
+
+---A newer API for adding items to the root.
+---Only adds the item if the same item does not already exist in the root.
+---@param item Item|string
+---@param priority? number Specifies the insertion location, a lower priority
+--- is placed higher in the list and the default is 100.
+---@return boolean
+function fb.register_root_item(item, priority)
+ msg.verbose('registering root item:', utils.to_string(item))
+ if type(item) == 'string' then
+ item = {name = item, type = 'dir'}
+ end
+
+ -- if the item is already in the list then do nothing
+ if fb.list.some(g.root, function(r)
+ return fb.get_full_path(r, '') == fb.get_full_path(item, '')
+ end) then return false end
+
+ ---@type table<Item,number>
+ local priorities = {}
+
+ priorities[item] = priority
+ for i, v in ipairs(g.root) do
+ if (priorities[v] or 100) > (priority or 100) then
+ fb.insert_root_item(item, i)
+ return true
+ end
+ end
+ fb.insert_root_item(item)
+ return true
+end
+
+--providing getter and setter functions so that addons can't modify things directly
+
+
+---@param key string
+---@return boolean|string|number
+function fb.get_opt(key) return o[key] end
+
+function fb.get_script_opts() return fb.copy_table(o) end
+function fb.get_platform() return g.PLATFORM end
+function fb.get_extensions() return fb.copy_table(g.extensions) end
+function fb.get_sub_extensions() return fb.copy_table(g.sub_extensions) end
+function fb.get_audio_extensions() return fb.copy_table(g.audio_extensions) end
+function fb.get_parseable_extensions() return fb.copy_table(g.parseable_extensions) end
+function fb.get_state() return fb.copy_table(g.state) end
+function fb.get_parsers() return fb.copy_table(g.parsers) end
+function fb.get_root() return fb.copy_table(g.root) end
+function fb.get_directory() return g.state.directory end
+function fb.get_list() return fb.copy_table(g.state.list) end
+function fb.get_current_file() return fb.copy_table(g.current_file) end
+function fb.get_current_parser() return g.state.parser:get_id() end
+function fb.get_current_parser_keyname() return g.state.parser.keybind_name or g.state.parser.name end
+function fb.get_selected_index() return g.state.selected end
+function fb.get_selected_item() return fb.copy_table(g.state.list[g.state.selected]) end
+function fb.get_open_status() return not g.state.hidden end
+function fb.get_parse_state(co) return g.parse_states[co or coroutine.running() or ""] end
+function fb.get_history() return fb.copy_table(g.history.list) end
+function fb.get_history_index() return g.history.position end
+
+---@deprecated
+---@return string|nil
+function fb.get_dvd_device()
+ local dvd_device = mp.get_property('dvd-device')
+ if not dvd_device or dvd_device == '' then return nil end
+ return fb_utils.fix_path(dvd_device, true)
+end
+
+---@param str string
+function fb.set_empty_text(str)
+ g.state.empty_text = str
+ fb.redraw()
+end
+
+---@param index number
+---@return number|false
+function fb.set_selected_index(index)
+ if type(index) ~= "number" then return false end
+ if index < 1 then index = 1 end
+ if index > #g.state.list then index = #g.state.list end
+ g.state.selected = index
+ fb.redraw()
+ return index
+end
+
+fb.set_history_index = directory_movement.goto_history
+
+return fb
diff --git a/mpv/scripts/file-browser/modules/apis/parse-state.lua b/mpv/scripts/file-browser/modules/apis/parse-state.lua
new file mode 100644
index 0000000..6db5658
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/apis/parse-state.lua
@@ -0,0 +1,34 @@
+
+local msg = require 'mp.msg'
+
+local g = require 'modules.globals'
+
+---@class ParseStateAPI
+local parse_state_API = {}
+
+---A wrapper around coroutine.yield that aborts the coroutine if
+--the parse request was cancelled by the user.
+--the coroutine is
+---@async
+---@param self ParseState
+---@param ... any
+---@return unknown ...
+function parse_state_API:yield(...)
+ local co = coroutine.running()
+ local is_browser = co == g.state.co
+
+ local result = table.pack(coroutine.yield(...))
+ if is_browser and co ~= g.state.co then
+ msg.verbose("browser no longer waiting for list - aborting parse for", self.directory)
+ error(g.ABORT_ERROR)
+ end
+ return table.unpack(result, 1, result.n)
+end
+
+---Checks if the current coroutine is the one handling the browser's request.
+---@return boolean
+function parse_state_API:is_coroutine_current()
+ return coroutine.running() == g.state.co
+end
+
+return parse_state_API
diff --git a/mpv/scripts/file-browser/modules/apis/parser.lua b/mpv/scripts/file-browser/modules/apis/parser.lua
new file mode 100644
index 0000000..9c3fbeb
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/apis/parser.lua
@@ -0,0 +1,40 @@
+local msg = require 'mp.msg'
+
+local g = require 'modules.globals'
+local scanning = require 'modules.navigation.scanning'
+local fb = require 'modules.apis.fb'
+
+---@class ParserAPI: FbAPI
+local parser_api = setmetatable({}, { __index = fb })
+
+---Returns the index of the parser.
+---@return number
+function parser_api:get_index() return g.parsers[self].index end
+
+---Returns the ID of the parser
+---@return string
+function parser_api:get_id() return g.parsers[self].id end
+
+---A newer API for adding items to the root.
+---Only adds the item if the same item does not already exist in the root.
+---Wrapper around `fb.register_root_item`.
+---@param item Item|string
+---@param priority? number The priority for the added item. Uses the parsers priority by default.
+---@return boolean
+function parser_api:register_root_item(item, priority)
+ return fb.register_root_item(item, priority or g.parsers[self:get_id()].priority)
+end
+
+---Runs choose_and_parse starting from the next parser.
+---@async
+---@param directory string
+---@return Item[]?
+---@return Opts?
+function parser_api:defer(directory)
+ msg.trace("deferring to other parsers...")
+ local list, opts = scanning.choose_and_parse(directory, self:get_index() + 1)
+ fb.get_parse_state().already_deferred = true
+ return list, opts
+end
+
+return parser_api
diff --git a/mpv/scripts/file-browser/modules/ass.lua b/mpv/scripts/file-browser/modules/ass.lua
new file mode 100644
index 0000000..a64a47d
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/ass.lua
@@ -0,0 +1,225 @@
+--------------------------------------------------------------------------------------------------------
+-----------------------------------------List Formatting------------------------------------------------
+--------------------------------------------------------------------------------------------------------
+--------------------------------------------------------------------------------------------------------
+
+local g = require 'modules.globals'
+local o = require 'modules.options'
+local fb_utils = require 'modules.utils'
+
+local state = g.state
+local style = g.style
+local ass = g.ass
+
+local function draw()
+ ass:update()
+end
+
+local function remove()
+ ass:remove()
+end
+
+---@type string[]
+local string_buffer = {}
+
+---appends the entered text to the overlay
+---@param ... string
+local function append(...)
+ for i = 1, select("#", ...) do
+ table.insert(string_buffer, select(i, ...) or '' )
+ end
+end
+
+--appends a newline character to the osd
+local function newline()
+ table.insert(string_buffer, '\\N')
+end
+
+local function flush_buffer()
+ ass.data = table.concat(string_buffer, '')
+ string_buffer = {}
+end
+
+---detects whether or not to highlight the given entry as being played
+---@param v Item
+---@return boolean
+local function highlight_entry(v)
+ if g.current_file.path == nil then return false end
+ local full_path = fb_utils.get_full_path(v)
+ local alt_path = v.name and g.state.directory..v.name or nil
+
+ if fb_utils.parseable_item(v) then
+ return (
+ string.find(g.current_file.directory, full_path, 1, true)
+ or (alt_path and string.find(g.current_file.directory, alt_path, 1, true))
+ ) ~= nil
+ else
+ return g.current_file.path == full_path
+ or (alt_path and g.current_file.path == alt_path)
+ end
+end
+
+---escape ass values and replace newlines
+---@param str string
+---@param style_reset string?
+---@return string
+local function ass_escape(str, style_reset)
+ return fb_utils.ass_escape(str, style_reset and style.warning..'␊'..style_reset or true)
+end
+
+local header_overrides = {['^'] = style.header}
+
+---@return number start
+---@return number finish
+---@return boolean is_overflowing
+local function calculate_view_window()
+ ---@type number
+ local start = 1
+ ---@type number
+ local finish = start+o.num_entries-1
+
+ --handling cursor positioning
+ local mid = math.ceil(o.num_entries/2)+1
+ if state.selected+mid > finish then
+ ---@type number
+ local offset = state.selected - finish + mid
+
+ --if we've overshot the end of the list then undo some of the offset
+ if finish + offset > #state.list then
+ offset = offset - ((finish+offset) - #state.list)
+ end
+
+ start = start + offset
+ finish = finish + offset
+ end
+
+ --making sure that we don't overstep the boundaries
+ if start < 1 then start = 1 end
+ local overflow = finish < #state.list
+ --this is necessary when the number of items in the dir is less than the max
+ if not overflow then finish = #state.list end
+
+ return start, finish, overflow
+end
+
+---@param i number index
+---@return string
+local function calculate_item_style(i)
+ local is_playing_file = highlight_entry(state.list[i])
+
+ --sets the selection colour scheme
+ local multiselected = state.selection[i]
+
+ --sets the colour for the item
+ local item_style = style.body
+
+ if multiselected then item_style = item_style..style.multiselect
+ elseif i == state.selected then item_style = item_style..style.selected end
+
+ if is_playing_file then item_style = item_style..(multiselected and style.playing_selected or style.playing) end
+
+ return item_style
+end
+
+local function draw_header()
+ append(style.header)
+ append(fb_utils.substitute_codes(o.format_string_header, header_overrides, nil, nil, function(str, code)
+ if code == '^' then return str end
+ return ass_escape(str, style.header)
+ end))
+ newline()
+end
+
+---@param wrapper_overrides ReplacerTable
+local function draw_top_wrapper(wrapper_overrides)
+ --adding a header to show there are items above in the list
+ append(style.footer_header)
+ append(fb_utils.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, function(str)
+ return ass_escape(str)
+ end))
+ newline()
+end
+
+---@param wrapper_overrides ReplacerTable
+local function draw_bottom_wrapper(wrapper_overrides)
+ append(style.footer_header)
+ append(fb_utils.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, function(str)
+ return ass_escape(str)
+ end))
+end
+
+---@param i number index
+---@param cursor string
+local function draw_cursor(i, cursor)
+ --handles custom styles for different entries
+ if i == state.selected or i == state.multiselect_start then
+ if not (i == state.selected) then append(style.selection_marker) end
+
+ if not state.multiselect_start then append(style.cursor)
+ else
+ if state.selection[state.multiselect_start] then append(style.cursor_select)
+ else append(style.cursor_deselect) end
+ end
+ else
+ append(g.style.indent)
+ end
+ append(cursor, '\\h', style.body)
+end
+
+--refreshes the ass text using the contents of the list
+local function update_ass()
+ if state.hidden then state.flag_update = true ; return end
+
+ append(style.global)
+ draw_header()
+
+ if #state.list < 1 then
+ append(state.empty_text)
+ flush_buffer()
+ draw()
+ return
+ end
+
+ local start, finish, overflow = calculate_view_window()
+
+ -- these are the number values to place into the wrappers
+ local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)}
+ if o.format_string_topwrapper ~= '' and start > 1 then
+ draw_top_wrapper(wrapper_overrides)
+ end
+
+ for i=start, finish do
+ local v = state.list[i]
+ append(style.body)
+ if g.ALIGN_X ~= 'right' then draw_cursor(i, o.cursor_icon) end
+
+ local item_style = calculate_item_style(i)
+ append(item_style)
+
+ --sets the folder icon
+ if v.type == 'dir' then
+ append(style.folder, o.folder_icon, "\\h", style.body)
+ append(item_style)
+ end
+
+ --adds the actual name of the item
+ append(v.ass or ass_escape(v.label or v.name, item_style), '\\h')
+ if g.ALIGN_X == 'right' then draw_cursor(i, o.cursor_icon_flipped) end
+ newline()
+ end
+
+ if o.format_string_bottomwrapper ~= '' and overflow then
+ draw_bottom_wrapper(wrapper_overrides)
+ end
+
+ flush_buffer()
+ draw()
+end
+
+---@class ass
+return {
+ update_ass = update_ass,
+ highlight_entry = highlight_entry,
+ draw = draw,
+ remove = remove,
+}
diff --git a/mpv/scripts/file-browser/modules/controls.lua b/mpv/scripts/file-browser/modules/controls.lua
new file mode 100644
index 0000000..6b124c2
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/controls.lua
@@ -0,0 +1,94 @@
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local movement = require 'modules.navigation.directory-movement'
+local ass = require 'modules.ass'
+local cursor = require 'modules.navigation.cursor'
+
+---@class controls
+local controls = {}
+
+--opens the browser
+function controls.open()
+ if not g.state.hidden then return end
+
+ for _,v in ipairs(g.state.keybinds) do
+ mp.add_forced_key_binding(v[1], 'dynamic/'..v[2], v[3], v[4])
+ end
+
+ if o.set_shared_script_properties then utils.shared_script_property_set('file_browser-open', 'yes') end ---@diagnostic disable-line deprecated
+ if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', true) end
+
+ if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'no', 'no_osd') end
+ g.state.hidden = false
+ if g.state.directory == nil then
+ local path = mp.get_property('path')
+ if path or o.default_to_working_directory then movement.goto_current_dir() else movement.goto_root() end
+ return
+ end
+
+ if not g.state.flag_update then ass.draw()
+ else g.state.flag_update = false ; ass.update_ass() end
+end
+
+--closes the list and sets the hidden flag
+function controls.close()
+ if g.state.hidden then return end
+
+ for _,v in ipairs(g.state.keybinds) do
+ mp.remove_key_binding('dynamic/'..v[2])
+ end
+
+ if o.set_shared_script_properties then utils.shared_script_property_set("file_browser-open", "no") end ---@diagnostic disable-line deprecated
+ if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', false) end
+
+ if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'yes', 'no_osd') end
+ g.state.hidden = true
+ ass.remove()
+end
+
+--toggles the list
+function controls.toggle()
+ if g.state.hidden then controls.open()
+ else controls.close() end
+end
+
+--run when the escape key is used
+function controls.escape()
+ --if multiple items are selection cancel the
+ --selection instead of closing the browser
+ if next(g.state.selection) or g.state.multiselect_start then
+ g.state.selection = {}
+ cursor.disable_select_mode()
+ ass.update_ass()
+ return
+ end
+ controls.close()
+end
+
+---opens a specific directory
+---@param directory string
+---@param open_browser? boolean
+---@return thread|nil
+function controls.browse_directory(directory, open_browser)
+ if not directory then return end
+ if open_browser == nil then open_browser = true end
+
+ directory = mp.command_native({"expand-path", directory}, '') --[[@as string]]
+ -- directory = join_path( mp.get_property("working-directory", ""), directory )
+
+ if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
+ msg.verbose('recieved directory from script message: '..directory)
+
+ directory = fb_utils.resolve_directory_mapping(directory)
+ local co = movement.goto_directory(directory, nil, nil, {cache={use=false}})
+ if open_browser then controls.open() end
+ return co
+end
+
+return controls
diff --git a/mpv/scripts/file-browser/modules/defs/file-browser.lua b/mpv/scripts/file-browser/modules/defs/file-browser.lua
new file mode 100644
index 0000000..055d4df
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/file-browser.lua
@@ -0,0 +1,3 @@
+---@meta file-browser
+
+return require 'modules.apis.fb'
diff --git a/mpv/scripts/file-browser/modules/defs/keybind.lua b/mpv/scripts/file-browser/modules/defs/keybind.lua
new file mode 100644
index 0000000..a903d46
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/keybind.lua
@@ -0,0 +1,39 @@
+---@meta _
+
+---@class KeybindFlags
+---@field repeatable boolean?
+---@field scalable boolean?
+---@field complex boolean?
+
+
+---@class KeybindCommandTable
+
+
+---@class Keybind
+---@field key string
+---@field command KeybindCommand
+---@field api_version string?
+---
+---@field name string?
+---@field condition string?
+---@field flags KeybindFlags?
+---@field filter ('file'|'dir')?
+---@field parser string?
+---@field multiselect boolean?
+---@field multi-type ('repeat'|'concat')?
+---@field delay number?
+---@field concat-string string?
+---@field passthrough boolean?
+---
+---@field prev_key Keybind? The keybind that was previously set to the same key.
+---@field codes Set<string>? Any substituation codes used by the command table.
+---@field condition_codes Set<string>? Any substitution codes used by the condition string.
+---@field addon boolean? Whether the keybind was created by an addon.
+
+
+---@alias KeybindFunctionCallback async fun(keybind: Keybind, state: State, co: thread)
+
+---@alias KeybindCommand KeybindFunctionCallback|KeybindCommandTable[]
+---@alias KeybindTuple [string,string,KeybindCommand,KeybindFlags?]
+---@alias KeybindTupleStrict [string,string,KeybindFunctionCallback,KeybindFlags?]
+---@alias KeybindList (Keybind|KeybindTuple)[]
diff --git a/mpv/scripts/file-browser/modules/defs/list.lua b/mpv/scripts/file-browser/modules/defs/list.lua
new file mode 100644
index 0000000..b2133b6
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/list.lua
@@ -0,0 +1,25 @@
+---@meta _
+
+---@alias List Item[]
+
+---Represents an item returned by the parsers.
+---@class Item
+---@field type 'file'|'dir'
+---@field name string
+---@field label string?
+---@field path string?
+---@field ass string?
+---@field redirect boolean?
+---@field mpv_options string|{[string]: unknown}?
+
+
+---The Opts table returned by the parsers.
+---@class Opts
+---@field filtered boolean?
+---@field sorted boolean?
+---@field directory string?
+---@field directory_label string?
+---@field empty_text string?
+---@field selected_index number?
+---@field id string?
+---@field parser Parser? \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/defs/mp/defaults.lua b/mpv/scripts/file-browser/modules/defs/mp/defaults.lua
new file mode 100644
index 0000000..5a86dce
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/mp/defaults.lua
@@ -0,0 +1,148 @@
+---@meta mp
+
+---@class mp
+local mp = {}
+
+---@class AsyncReturn
+
+---@class MPTimer
+---@field stop fun(self: MPTimer)
+---@field kill fun(self: MPTimer)
+---@field resume fun(self: MPTimer)
+---@field is_enabled fun(self: MPTimer): boolean
+---@field timeout number
+---@field oneshot boolean
+
+---@class OSDOverlay
+---@field data string
+---@field res_x number
+---@field res_y number
+---@field z number
+---@field update fun(self:OSDOverlay)
+---@field remove fun(self: OSDOverlay)
+
+---@class MPVSubprocessResult
+---@field status number
+---@field stdout string
+---@field stderr string
+---@field error_string ''|'killed'|'init'
+---@field killed_by_us boolean
+
+---@param key string
+---@param name_or_fn string|function
+---@param fn? async fun()
+---@param flags? KeybindFlags
+function mp.add_key_binding(key, name_or_fn, fn, flags) end
+
+---@param key string
+---@param name_or_fn string|function
+---@param fn? async fun()
+---@param flags? KeybindFlags
+function mp.add_forced_key_binding(key, name_or_fn, fn, flags) end
+
+---@param seconds number
+---@param fn function
+---@param disabled? boolean
+---@return MPTimer
+function mp.add_timeout(seconds, fn, disabled) end
+
+---@param format 'ass-events'
+---@return OSDOverlay
+function mp.create_osd_overlay(format) end
+
+---@param ... string
+function mp.commandv(...) end
+
+---@generic T
+---@param t table
+---@param def? T
+---@return unknown|T result
+---@return string? error
+---@overload fun(t: table): (unknown|nil, string?)
+function mp.command_native(t, def) end
+
+---@nodiscard
+---@param t table
+---@param cb fun(success: boolean, result: unknown, error: string?)
+---@return AsyncReturn
+function mp.command_native_async(t, cb) end
+
+---@param t AsyncReturn
+function mp.abort_async_command(t) end
+
+---@generic T
+---@param name string
+---@param def? T
+---@return string|T
+---@overload fun(name: string): string|nil
+function mp.get_property(name, def) end
+
+---@generic T
+---@param name string
+---@param def? T
+---@return boolean|T
+---@overload fun(name: string): boolean|nil
+function mp.get_property_bool(name, def) end
+
+---@generic T
+---@param name string
+---@param def? T
+---@return number|T
+---@overload fun(name: string): number|nil
+function mp.get_property_number(name, def) end
+
+---@generic T
+---@param name string
+---@param def? T
+---@return unknown|T
+---@overload fun(name: string): unknown|nil
+function mp.get_property_native(name, def) end
+
+---@return string|nil
+function mp.get_script_directory() end
+
+---@return string
+function mp.get_script_name() end
+
+---@param name string
+---@param type 'native'|'bool'|'string'|'number'
+---@param fn fun(name: string, v: unknown)
+function mp.observe_property(name, type, fn) end
+
+---@param name string
+---@param fn function
+---@return boolean
+function mp.register_event(name, fn) end
+
+---@param name string
+---@param fn fun(...: string)
+function mp.register_script_message(name, fn) end
+
+---@param name string
+function mp.remove_key_binding(name) end
+
+---@param name string
+---@param value string
+---@return true? success # nil if error
+---@return string? err
+function mp.set_property(name, value) end
+
+---@param name string
+---@param value boolean
+---@return true? success # nil if error
+---@return string? err
+function mp.set_property_bool(name, value) end
+
+---@param name string
+---@param value number
+---@return true? success # nil if error
+---@return string? err
+function mp.set_property_number(name, value) end
+
+---@param name string
+---@param value any
+---@return true? success # nil if error
+---@return string? err
+function mp.set_property_native(name, value) end
+
+return mp \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/defs/mp/input.lua b/mpv/scripts/file-browser/modules/defs/mp/input.lua
new file mode 100644
index 0000000..aab300c
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/mp/input.lua
@@ -0,0 +1,21 @@
+---@meta mp.input
+
+---@class mp.input
+local input = {}
+
+---@class InputGetOpts
+---@field prompt string?
+---@field default_text string?
+---@field id string?
+---@field submit (fun(text: string))?
+---@field opened (fun())?
+---@field edited (fun(text: string))?
+---@field complete (fun(text_before_cursor: string): string[], number)?
+---@field closed (fun(text: string))?
+
+---@param options InputGetOpts
+function input.get(options) end
+
+function input.terminate() end
+
+return input \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/defs/mp/msg.lua b/mpv/scripts/file-browser/modules/defs/mp/msg.lua
new file mode 100644
index 0000000..e260ec1
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/mp/msg.lua
@@ -0,0 +1,32 @@
+---@meta mp.msg
+
+---@class mp.msg
+local msg = {}
+
+---@param level 'fatal'|'error'|'warn'|'info'|'v'|'debug'|'trace'
+---@param ... any
+function msg.log(level, ...) end
+
+---@param ... any
+function msg.fatal(...) end
+
+---@param ... any
+function msg.error(...) end
+
+---@param ... any
+function msg.warn(...) end
+
+---@param ... any
+function msg.info(...) end
+
+---@param ... any
+function msg.verbose(...) end
+
+---@param ... any
+function msg.debug(...) end
+
+---@param ... any
+function msg.trace(...) end
+
+
+return msg \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/defs/mp/options.lua b/mpv/scripts/file-browser/modules/defs/mp/options.lua
new file mode 100644
index 0000000..0feff23
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/mp/options.lua
@@ -0,0 +1,11 @@
+---@meta mp.options
+
+---@class mp.options
+local options = {}
+
+---@param t table<string,string|number|boolean>
+---@param identifier? string
+---@param on_update? fun(list: table<string,true|nil>)
+function options.read_options(t, identifier, on_update) end
+
+return options
diff --git a/mpv/scripts/file-browser/modules/defs/mp/utils.lua b/mpv/scripts/file-browser/modules/defs/mp/utils.lua
new file mode 100644
index 0000000..59eae32
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/mp/utils.lua
@@ -0,0 +1,43 @@
+---@meta mp.utils
+
+---@class mp.utils
+local utils = {}
+
+---@param v string|boolean|number|table|nil
+---@return string? json # nil on error
+---@return string? err # error
+function utils.format_json(v) end
+
+---@param p1 string
+---@param p2 string
+---@return string
+function utils.join_path(p1, p2) end
+
+---@param str string
+---@param trail? boolean
+---@return (table|unknown[])? t
+---@return string? err # error
+---@return string trail # trailing characters
+function utils.parse_json(str, trail) end
+
+---@param path string
+---@param filter ('files'|'dirs'|'normal'|'all')?
+---@return string[]? # nil on error
+---@return string? err # error
+function utils.readdir(path, filter) end
+
+---@deprecated
+---@param name string
+---@param value string
+function utils.shared_script_property_set(name, value) end
+
+---@param path string
+---@return string directory
+---@return string filename
+function utils.split_path(path) end
+
+---@param v any
+---@return string
+function utils.to_string(v) end
+
+return utils
diff --git a/mpv/scripts/file-browser/modules/defs/parser.lua b/mpv/scripts/file-browser/modules/defs/parser.lua
new file mode 100644
index 0000000..d196bf2
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/parser.lua
@@ -0,0 +1,41 @@
+---@meta _
+
+---A ParserConfig object returned by addons
+---@class (partial) ParserConfig: ParserAPI
+---@field priority number?
+---@field api_version string The minimum API version the string requires.
+---@field version string? The minimum API version the string requires. @deprecated.
+---
+---@field can_parse (async fun(self: Parser, directory: string, parse_state: ParseState): boolean)?
+---@field parse (async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?)?
+---@field setup fun(self: Parser)?
+---
+---@field name string?
+---@field keybind_name string?
+---@field keybinds KeybindList?
+
+
+---The parser object used by file-browser once the parsers have been loaded and initialised.
+---@class Parser: ParserAPI, ParserConfig
+---@field name string
+---@field priority number
+---@field api_version string
+---@field can_parse async fun(self: Parser, directory: string, parse_state: ParseState): boolean
+---@field parse async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?
+
+
+---@alias ParseStateSource 'browser'|'loadlist'|'script-message'|'addon'|string
+---@alias ParseProperties table<string,any>
+
+---The Parse State object passed to the can_parse and parse methods
+---@class ParseStateFields
+---@field source ParseStateSource
+---@field directory string
+---@field already_deferred boolean?
+---@field properties ParseProperties
+
+---@class ParseState: ParseStateFields, ParseStateAPI
+
+---@class ParseStateTemplate
+---@field source ParseStateSource?
+---@field properties ParseProperties?
diff --git a/mpv/scripts/file-browser/modules/defs/state.lua b/mpv/scripts/file-browser/modules/defs/state.lua
new file mode 100644
index 0000000..403c310
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/state.lua
@@ -0,0 +1,21 @@
+---@meta _
+
+---@class Set<T>: {[T]: boolean}
+
+---@class (exact) State
+---@field list List
+---@field selected number
+---@field hidden boolean
+---@field flag_update boolean
+---@field keybinds KeybindTupleStrict[]?
+---
+---@field parser Parser?
+---@field directory string?
+---@field directory_label string?
+---@field prev_directory string
+---@field empty_text string
+---@field co thread?
+---
+---@field multiselect_start number?
+---@field initial_selection Set<number>?
+---@field selection Set<number>? \ No newline at end of file
diff --git a/mpv/scripts/file-browser/modules/defs/user-input.lua b/mpv/scripts/file-browser/modules/defs/user-input.lua
new file mode 100644
index 0000000..a046095
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/defs/user-input.lua
@@ -0,0 +1,28 @@
+---@meta user-input-module
+
+---@class user_input_module
+local user_input_module = {}
+
+---@class UserInputOpts
+---@field id string?
+---@field source string?
+---@field request_text string?
+---@field default_input string?
+---@field cursor_pos number?
+---@field queueable boolean?
+---@field replace boolean?
+
+---@class UserInputRequest
+---@field callback function?
+---@field passthrough_args any[]?
+---@field pending boolean
+---@field cancel fun(self: UserInputRequest)
+---@field update fun(self: UserInputRequest, opts: UserInputOpts)
+
+---@param fn function
+---@param opts UserInputOpts
+---@param ... any passthrough arguments
+---@return UserInputRequest
+function user_input_module.get_user_input(fn, opts, ...) end
+
+return user_input_module
diff --git a/mpv/scripts/file-browser/modules/globals.lua b/mpv/scripts/file-browser/modules/globals.lua
new file mode 100644
index 0000000..f4aa1b6
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/globals.lua
@@ -0,0 +1,181 @@
+--------------------------------------------------------------------------------------------------------
+------------------------------------------Variable Setup------------------------------------------------
+--------------------------------------------------------------------------------------------------------
+--------------------------------------------------------------------------------------------------------
+
+local mp = require 'mp'
+local o = require 'modules.options'
+
+---@class globals
+local globals = {}
+
+--sets the version for the file-browser API
+globals.API_VERSION = "1.9.0"
+
+---gets the current platform (in mpv v0.36+)
+---in earlier versions it is set to `windows`, `darwin` or `other`
+---@type 'windows'|'darwin'|'linux'|'android'|'freebsd'|'other'|string|nil
+globals.PLATFORM = mp.get_property_native('platform')
+if not globals.PLATFORM then
+ local _ = {}
+ if mp.get_property_native('options/vo-mmcss-profile', _) ~= _ then
+ globals.PLATFORM = 'windows'
+ elseif mp.get_property_native('options/macos-force-dedicated-gpu', _) ~= _ then
+ globals.PLATFORM = 'darwin'
+ end
+ return 'other'
+end
+
+--the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30
+assert(mp.create_osd_overlay, "Script requires minimum mpv version 0.33")
+
+globals.ass = mp.create_osd_overlay("ass-events")
+globals.ass.res_y = 720 / o.scaling_factor_base
+
+local BASE_FONT_SIZE = 25
+
+--force file-browser to use a specific text alignment (default: top-left)
+--uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
+globals.ASS_ALIGNMENT_MATRIX = {
+ top = {left = 7, center = 8, right = 9},
+ center = {left = 4, center = 5, right = 6},
+ bottom = {left = 1, center = 2, right = 3},
+}
+
+globals.ALIGN_X = o.align_x == 'auto' and mp.get_property('osd-align-x', 'left') or o.align_x
+globals.ALIGN_Y = o.align_y == 'auto' and mp.get_property('osd-align-y', 'top') or o.align_y
+
+globals.style = {
+ global = ([[{\an%d}]]):format(globals.ASS_ALIGNMENT_MATRIX[globals.ALIGN_Y][globals.ALIGN_X]),
+
+ -- full line styles
+ 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),
+ body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(BASE_FONT_SIZE, o.font_name_body, o.font_colour_body),
+ 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),
+
+ --small section styles (for colours)
+ multiselect = ([[{\c&H%s&}]]):format(o.font_colour_multiselect),
+ selected = ([[{\c&H%s&}]]):format(o.font_colour_selected),
+ playing = ([[{\c&H%s&}]]):format(o.font_colour_playing),
+ playing_selected = ([[{\c&H%s&}]]):format(o.font_colour_playing_multiselected),
+ warning = ([[{\c&H%s&}]]):format(o.font_colour_escape_chars),
+
+ --icon styles
+ indent = ([[{\alpha&H%s}]]):format('ff'),
+ cursor = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_cursor),
+ cursor_select = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_multiselect),
+ cursor_deselect = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_selected),
+ folder = ([[{\fn%s}]]):format(o.font_name_folder),
+ selection_marker = ([[{\alpha&H%s}]]):format(o.font_opacity_selection_marker),
+}
+
+---@type State
+globals.state = {
+ list = {},
+ selected = 1,
+ hidden = true,
+ flag_update = false,
+ keybinds = nil,
+
+ parser = nil,
+ directory = nil,
+ directory_label = nil,
+ prev_directory = '',
+ empty_text = 'Empty Directory',
+ co = nil,
+
+ multiselect_start = nil,
+ initial_selection = nil,
+ selection = {}
+}
+
+---@class ParserRef
+---@field id string
+---@field index number?
+
+---@type table<number,Parser>|table<string,Parser>|table<Parser,ParserRef>>
+--the parser table actually contains 3 entries for each parser
+--a numeric entry which represents the priority of the parsers and has the parser object as the value
+--a string entry representing the id of each parser and with the parser object as the value
+--and a table entry with the parser itself as the key and a table value in the form { id = %s, index = %d }
+globals.parsers = {}
+
+--this table contains the parse_state tables for every parse operation indexed with the coroutine used for the parse
+--this table has weakly referenced keys, meaning that once the coroutine for a parse is no-longer used by anything that
+--field in the table will be removed by the garbage collector
+---@type table<thread,ParseState>
+globals.parse_states = setmetatable({}, { __mode = "k"})
+
+---@type Set<string>
+globals.extensions = {}
+
+---@type Set<string>
+globals.sub_extensions = {}
+
+---@type Set<string>
+globals.audio_extensions = {}
+
+---@type Set<string>
+globals.parseable_extensions = {}
+
+---This table contains mappings to convert external directories to cannonical
+--locations within the file-browser file tree. The keys of the table are Lua
+--patterns used to evaluate external directory paths. The value is the path
+--that should replace the part of the path than matched the pattern.
+--These mappings should only applied at the edges where external paths are
+--ingested by file-browser.
+---@type table<string,string>
+globals.directory_mappings = {}
+
+---@class CurrentFile
+---@field directory string?
+---@field name string?
+---@field path string?
+---@field original_path string?
+globals.current_file = {
+ directory = nil,
+ name = nil,
+ path = nil,
+ original_path = nil,
+}
+
+---@type List
+globals.root = {}
+
+---@class (strict) History
+---@field list string[]
+---@field size number
+---@field position number
+globals.history = {
+ list = {},
+ size = 0,
+ position = 0,
+}
+
+---@class (strict) DirectoryStack
+---@field stack string[]
+---@field position number
+globals.directory_stack = {
+ stack = {},
+ position = 0,
+}
+
+
+--default list of compatible file extensions
+--adding an item to this list is a valid request on github
+globals.compatible_file_extensions = {
+ "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",
+ "bmp","cue","divx","dts","dtshd","dts-hd","dv","dvr","dvr-ms","eac3","evo","evob","f4a","flac","flc","fli","flic","flv","gbs","gif","gxf","gym",
+ "h264","h265","hdmov","hdv","hes","hevc","jpeg","jpg","kss","lpcm","m1a","m1v","m2a","m2t","m2ts","m2v","m3u","m3u8","m4a","m4v","mk3d","mka","mkv",
+ "mlp","mod","mov","mp1","mp2","mp2v","mp3","mp4","mp4v","mp4v","mpa","mpe","mpeg","mpeg2","mpeg4","mpg","mpg4","mpv","mpv2","mts","mtv","mxf","nsf",
+ "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",
+ "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",
+ "wv","x264","x265","xvid","y4m","yuv"
+}
+
+---@class BrowserAbortError
+globals.ABORT_ERROR = {
+ msg = "browser is no longer waiting for list - aborting parse"
+}
+
+return globals
diff --git a/mpv/scripts/file-browser/modules/keybinds.lua b/mpv/scripts/file-browser/modules/keybinds.lua
new file mode 100644
index 0000000..3950538
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/keybinds.lua
@@ -0,0 +1,354 @@
+------------------------------------------------------------------------------------------
+----------------------------------Keybind Implementation----------------------------------
+------------------------------------------------------------------------------------------
+------------------------------------------------------------------------------------------
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local addons = require 'modules.addons'
+local playlist = require 'modules.playlist'
+local controls = require 'modules.controls'
+local movement = require 'modules.navigation.directory-movement'
+local scanning = require 'modules.navigation.scanning'
+local cursor = require 'modules.navigation.cursor'
+
+g.state.keybinds = {
+ {'ENTER', 'play', function() playlist.add_files('replace', false) end},
+ {'Shift+ENTER', 'play_append', function() playlist.add_files('append-play', false) end},
+ {'Alt+ENTER', 'play_autoload', function() playlist.add_files('replace', true) end},
+ {'ESC', 'close', controls.escape},
+ {'RIGHT', 'down_dir', movement.down_dir},
+ {'LEFT', 'up_dir', movement.up_dir},
+ {'Alt+RIGHT', 'history_forward', movement.forwards_history},
+ {'Alt+LEFT', 'history_back', movement.back_history},
+ {'DOWN', 'scroll_down', function() cursor.scroll(1, o.wrap) end, {repeatable = true}},
+ {'UP', 'scroll_up', function() cursor.scroll(-1, o.wrap) end, {repeatable = true}},
+ {'PGDWN', 'page_down', function() cursor.scroll(o.num_entries) end, {repeatable = true}},
+ {'PGUP', 'page_up', function() cursor.scroll(-o.num_entries) end, {repeatable = true}},
+ {'Shift+PGDWN', 'list_bottom', function() cursor.scroll(math.huge) end},
+ {'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end},
+ {'HOME', 'goto_current', movement.goto_current_dir},
+ {'Shift+HOME', 'goto_root', movement.goto_root},
+ {'Ctrl+r', 'reload', function() scanning.rescan() end},
+ {'s', 'select_mode', cursor.toggle_select_mode},
+ {'S', 'select_item', cursor.toggle_selection},
+ {'Ctrl+a', 'select_all', cursor.select_all}
+}
+
+---a map of key-keybinds - only saves the latest keybind if multiple have the same key code
+---@type KeybindList
+local top_level_keys = {}
+
+---Format the item string for either single or multiple items.
+---@param base_code_fn Replacer
+---@param items Item[]
+---@param state State
+---@param cmd Keybind
+---@param quoted? boolean
+---@return string|nil
+local function create_item_string(base_code_fn, items, state, cmd, quoted)
+ if not items[1] then return end
+ local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn
+
+ local out = {}
+ for _, item in ipairs(items) do
+ table.insert(out, func(item, state))
+ end
+
+ return table.concat(out, cmd['concat-string'] or ' ')
+end
+
+local KEYBIND_CODE_PATTERN = fb_utils.get_code_pattern(fb_utils.code_fns)
+local item_specific_codes = 'fnij'
+
+---Replaces codes in the given string using the replacers.
+---@param str string
+---@param cmd Keybind
+---@param items Item[]
+---@param state State
+---@return string
+local function substitute_codes(str, cmd, items, state)
+ ---@type ReplacerTable
+ local overrides = {}
+
+ for code in item_specific_codes:gmatch('.') do
+ overrides[code] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd) end
+ overrides[code:upper()] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd, true) end
+ end
+
+ return fb_utils.substitute_codes(str, overrides, items[1], state)
+end
+
+---Iterates through the command table and substitutes special
+---character codes for the correct strings used for custom functions.
+---@param cmd Keybind
+---@param items Item[]
+---@param state State
+---@return KeybindCommand
+local function format_command_table(cmd, items, state)
+ local command = cmd.command
+ if type(command) == 'function' then return command end
+ ---@type string[][]
+ local copy = {}
+ for i = 1, #command do
+ ---@type string[]
+ copy[i] = {}
+
+ for j = 1, #command[i] do
+ copy[i][j] = substitute_codes(cmd.command[i][j], cmd, items, state)
+ end
+ end
+ return copy
+end
+
+---Runs all of the commands in the command table.
+---@param cmd Keybind key.command must be an array of command tables compatible with mp.command_native
+---@param items Item[] must be an array of multiple items (when multi-type ~= concat the array will be 1 long).
+---@param state State
+local function run_custom_command(cmd, items, state)
+ local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command
+ if type(custom_cmds) == 'function' then
+ error(('attempting to run a function keybind as a command table keybind\n%s'):format(utils.to_string(cmd)))
+ end
+
+ for _, custom_cmd in ipairs(custom_cmds) do
+ msg.debug("running command:", utils.to_string(custom_cmd))
+ mp.command_native(custom_cmd)
+ end
+end
+
+---returns true if the given code set has item specific codes (%f, %i, etc)
+---@param codes Set<string>
+---@return boolean
+local function has_item_codes(codes)
+ for code in pairs(codes) do
+ if item_specific_codes:find(code:lower(), 1, true) then return true end
+ end
+ return false
+end
+
+---Runs one of the custom commands.
+---@async
+---@param cmd Keybind
+---@param state State
+---@param co thread
+---@return boolean|nil
+local function run_custom_keybind(cmd, state, co)
+ --evaluates a condition and passes through the correct values
+ local function evaluate_condition(condition, items)
+ local cond = substitute_codes(condition, cmd, items, state)
+ return fb_utils.evaluate_string('return '..cond) == true
+ end
+
+ -- evaluates the string condition to decide if the keybind should be run
+ ---@type boolean
+ local do_item_condition
+ if cmd.condition then
+ if has_item_codes(cmd.condition_codes) then
+ do_item_condition = true
+ elseif not evaluate_condition(cmd.condition, {}) then
+ return false
+ end
+ end
+
+ if cmd.parser then
+ local parser_str = ' '..cmd.parser..' '
+ if not parser_str:find( '%W'..(state.parser.keybind_name or state.parser.name)..'%W' ) then return false end
+ end
+
+ --these are for the default keybinds, or from addons which use direct functions
+ if type(cmd.command) == 'function' then return cmd.command(cmd, cmd.addon and fb_utils.copy_table(state) or state, co) end
+
+ --the function terminates here if we are running the command on a single item
+ if not (cmd.multiselect and next(state.selection)) then
+ if cmd.filter then
+ if not state.list[state.selected] then return false end
+ if state.list[state.selected].type ~= cmd.filter then return false end
+ end
+
+ if cmd.codes then
+ --if the directory is empty, and this command needs to work on an item, then abort and fallback to the next command
+ if not state.list[state.selected] and has_item_codes(cmd.codes) then return false end
+ end
+
+ if do_item_condition and not evaluate_condition(cmd.condition, { state.list[state.selected] }) then
+ return false
+ end
+ run_custom_command(cmd, { state.list[state.selected] }, state)
+ return true
+ end
+
+ --runs the command on all multi-selected items
+ local selection = fb_utils.sort_keys(state.selection, function(item)
+ if do_item_condition and not evaluate_condition(cmd.condition, { item }) then return false end
+ return not cmd.filter or item.type == cmd.filter
+ end)
+ if not next(selection) then return false end
+
+ if cmd["multi-type"] == "concat" then
+ run_custom_command(cmd, selection, state)
+
+ elseif cmd["multi-type"] == "repeat" or cmd["multi-type"] == nil then
+ for i,_ in ipairs(selection) do
+ run_custom_command(cmd, {selection[i]}, state)
+
+ if cmd.delay then
+ mp.add_timeout(cmd.delay, function() fb_utils.coroutine.resume_err(co) end)
+ coroutine.yield()
+ end
+ end
+ end
+
+ --we passthrough by default if the command is not run on every selected item
+ if cmd.passthrough ~= nil then return end
+
+ local num_selection = 0
+ for _ in pairs(state.selection) do num_selection = num_selection+1 end
+ return #selection == num_selection
+end
+
+---Recursively runs the keybind functions, passing down through the chain
+---of keybinds with the same key value.
+---@async
+---@param keybind Keybind
+---@param state State
+---@param co thread
+local function run_keybind_recursive(keybind, state, co)
+ msg.trace("Attempting custom command:", utils.to_string(keybind))
+
+ if keybind.passthrough ~= nil then
+ run_custom_keybind(keybind, state, co)
+ if keybind.passthrough == true and keybind.prev_key then
+ run_keybind_recursive(keybind.prev_key, state, co)
+ end
+ else
+ if run_custom_keybind(keybind, state, co) == false and keybind.prev_key then
+ run_keybind_recursive(keybind.prev_key, state, co)
+ end
+ end
+end
+
+---A wrapper to run a custom keybind as a lua coroutine.
+---@param key Keybind
+local function run_keybind_coroutine(key)
+ msg.debug("Received custom keybind "..key.key)
+ local co = coroutine.create(run_keybind_recursive)
+
+ local state_copy = {
+ directory = g.state.directory,
+ directory_label = g.state.directory_label,
+ list = g.state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables
+ selected = g.state.selected,
+ selection = fb_utils.copy_table(g.state.selection),
+ parser = g.state.parser,
+ }
+ local success, err = coroutine.resume(co, key, state_copy, co)
+ if not success then
+ msg.error("error running keybind:", utils.to_string(key))
+ fb_utils.traceback(err, co)
+ end
+end
+
+---Scans the given command table to identify if they contain any custom keybind codes.
+---@param command_table KeybindCommand
+---@param codes Set<string>
+---@return Set<string>
+local function scan_for_codes(command_table, codes)
+ if type(command_table) ~= "table" then return codes end
+ for _, value in pairs(command_table) do
+ local type = type(value)
+ if type == "table" then
+ scan_for_codes(value, codes)
+ elseif type == "string" then
+ for code in value:gmatch(KEYBIND_CODE_PATTERN) do
+ codes[code] = true
+ end
+ end
+ end
+ return codes
+end
+
+---Inserting the custom keybind into the keybind array for declaration when file-browser is opened.
+---Custom keybinds with matching names will overwrite eachother.
+---@param keybind Keybind
+local function insert_custom_keybind(keybind)
+ -- api checking for the keybinds is optional, so set to a valid version if it does not exist
+ keybind.api_version = keybind.api_version or '1.0.0'
+ if not addons.check_api_version(keybind, 'keybind '..keybind.name) then return end
+
+ local command = keybind.command
+
+ --we'll always save the keybinds as either an array of command arrays or a function
+ if type(command) == "table" and type(command[1]) ~= "table" then
+ keybind.command = {command}
+ end
+
+ keybind.codes = scan_for_codes(keybind.command, {})
+ if not next(keybind.codes) then keybind.codes = nil end
+ keybind.prev_key = top_level_keys[keybind.key]
+
+ if keybind.condition then
+ keybind.condition_codes = {}
+ for code in string.gmatch(keybind.condition, KEYBIND_CODE_PATTERN) do keybind.condition_codes[code] = true end
+ end
+
+ table.insert(g.state.keybinds, {keybind.key, keybind.name, function() run_keybind_coroutine(keybind) end, keybind.flags or {}})
+ top_level_keys[keybind.key] = keybind
+end
+
+---Loading the custom keybinds.
+---Can either load keybinds from the config file, from addons, or from both.
+local function setup_keybinds()
+ --this is to make the default keybinds compatible with passthrough from custom keybinds
+ for _, keybind in ipairs(g.state.keybinds) do
+ top_level_keys[keybind[1]] = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
+ end
+
+ --this loads keybinds from addons
+ for i = #g.parsers, 1, -1 do
+ local parser = g.parsers[i]
+ if parser.keybinds then
+ for i, keybind in ipairs(parser.keybinds) do
+ --if addons use the native array command format, then we need to convert them over to the custom command format
+ if not keybind.key then keybind = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
+ else keybind = fb_utils.copy_table(keybind) end
+
+ keybind.name = g.parsers[parser].id.."/"..(keybind.name or tostring(i))
+ keybind.addon = true
+ insert_custom_keybind(keybind)
+ end
+ end
+ end
+
+ --loads custom keybinds from file-browser-keybinds.json
+ if o.custom_keybinds then
+ local path = mp.command_native({"expand-path", o.custom_keybinds_file}) --[[@as string]]
+ local custom_keybinds, err = io.open( path )
+ if not custom_keybinds then
+ msg.debug(err)
+ msg.verbose('could not read custom keybind file', path)
+ return
+ end
+
+ local json = custom_keybinds:read("*a")
+ custom_keybinds:close()
+
+ json = utils.parse_json(json)
+ if not json then return error("invalid json syntax for "..path) end
+
+ for i, keybind in ipairs(json --[[@as KeybindList]]) do
+ keybind.name = "custom/"..(keybind.name or tostring(i))
+ insert_custom_keybind(keybind)
+ end
+ end
+end
+
+---@class keybinds
+return {
+ setup_keybinds = setup_keybinds,
+}
diff --git a/mpv/scripts/file-browser/modules/navigation/cursor.lua b/mpv/scripts/file-browser/modules/navigation/cursor.lua
new file mode 100644
index 0000000..84e553e
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/navigation/cursor.lua
@@ -0,0 +1,134 @@
+--------------------------------------------------------------------------------------------------------
+--------------------------------Scroll/Select Implementation--------------------------------------------
+--------------------------------------------------------------------------------------------------------
+--------------------------------------------------------------------------------------------------------
+
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local ass = require 'modules.ass'
+
+---@class cursor
+local cursor = {}
+
+--disables multiselect
+function cursor.disable_select_mode()
+ g.state.multiselect_start = nil
+ g.state.initial_selection = nil
+end
+
+--enables multiselect
+function cursor.enable_select_mode()
+ g.state.multiselect_start = g.state.selected
+ g.state.initial_selection = fb_utils.copy_table(g.state.selection)
+end
+
+--calculates what drag behaviour is required for that specific movement
+local function drag_select(original_pos, new_pos)
+ if original_pos == new_pos then return end
+
+ local setting = g.state.selection[g.state.multiselect_start or -1]
+ for i = original_pos, new_pos, (new_pos > original_pos and 1 or -1) do
+ --if we're moving the cursor away from the starting point then set the selection
+ --otherwise restore the original selection
+ if i > g.state.multiselect_start then
+ if new_pos > original_pos then
+ g.state.selection[i] = setting
+ elseif i ~= new_pos then
+ g.state.selection[i] = g.state.initial_selection[i]
+ end
+ elseif i < g.state.multiselect_start then
+ if new_pos < original_pos then
+ g.state.selection[i] = setting
+ elseif i ~= new_pos then
+ g.state.selection[i] = g.state.initial_selection[i]
+ end
+ end
+ end
+end
+
+--moves the selector up and down the list by the entered amount
+function cursor.scroll(n, wrap)
+ local num_items = #g.state.list
+ if num_items == 0 then return end
+
+ local original_pos = g.state.selected
+
+ if original_pos + n > num_items then
+ g.state.selected = wrap and 1 or num_items
+ elseif original_pos + n < 1 then
+ g.state.selected = wrap and num_items or 1
+ else
+ g.state.selected = original_pos + n
+ end
+
+ if g.state.multiselect_start then drag_select(original_pos, g.state.selected) end
+ ass.update_ass()
+end
+
+--selects the first item in the list which is highlighted as playing
+function cursor.select_playing_item()
+ for i,item in ipairs(g.state.list) do
+ if ass.highlight_entry(item) then
+ g.state.selected = i
+ return
+ end
+ end
+end
+
+--scans the list for which item to select by default
+--chooses the folder that the script just moved out of
+--or, otherwise, the item highlighted as currently playing
+function cursor.select_prev_directory()
+ -- makes use of the directory stack to more exactly select the prev directory
+ local down_stack = g.directory_stack.stack[g.directory_stack.position + 1]
+ if down_stack then
+ for i, item in ipairs(g.state.list) do
+ if fb_utils.get_new_directory(item, g.state.directory) == down_stack then
+ g.state.selected = i
+ return
+ end
+ end
+ end
+
+ if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then
+ for i, item in ipairs(g.state.list) do
+ if
+ g.state.prev_directory:find(fb_utils.get_full_path(item), 1, true) or
+ g.state.prev_directory:find(fb_utils.get_new_directory(item, g.state.directory), 1, true)
+ then
+ g.state.selected = i
+ return
+ end
+ end
+ end
+
+ cursor.select_playing_item()
+end
+
+--toggles the selection
+function cursor.toggle_selection()
+ if not g.state.list[g.state.selected] then return end
+ g.state.selection[g.state.selected] = not g.state.selection[g.state.selected] or nil
+ ass.update_ass()
+end
+
+--select all items in the list
+function cursor.select_all()
+ for i,_ in ipairs(g.state.list) do
+ g.state.selection[i] = true
+ end
+ ass.update_ass()
+end
+
+--toggles select mode
+function cursor.toggle_select_mode()
+ if g.state.multiselect_start == nil then
+ cursor.enable_select_mode()
+ cursor.toggle_selection()
+ else
+ cursor.disable_select_mode()
+ ass.update_ass()
+ end
+end
+
+return cursor
diff --git a/mpv/scripts/file-browser/modules/navigation/directory-movement.lua b/mpv/scripts/file-browser/modules/navigation/directory-movement.lua
new file mode 100644
index 0000000..37e85c8
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/navigation/directory-movement.lua
@@ -0,0 +1,209 @@
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local ass = require 'modules.ass'
+local scanning = require 'modules.navigation.scanning'
+local fb_utils = require 'modules.utils'
+local cursor = require 'modules.navigation.cursor'
+
+---@class directory_movement
+local directory_movement = {}
+local NavType = scanning.NavType
+
+---Appends an item to the directory stack, wiping any
+---directories further ahead than the current position.
+---@param dir string
+local function directory_stack_append(dir)
+ -- don't clear the stack if we're re-entering the same directory
+ if g.directory_stack.stack[g.directory_stack.position + 1] == dir then
+ g.directory_stack.position = g.directory_stack.position + 1
+ return
+ end
+
+ local j = #g.directory_stack.stack
+ while g.directory_stack.position < j do
+ g.directory_stack.stack[j] = nil
+ j = j - 1
+ end
+ table.insert(g.directory_stack.stack, dir)
+ g.directory_stack.position = g.directory_stack.position + 1
+end
+
+---@param dir string
+local function directory_stack_prepend(dir)
+ table.insert(g.directory_stack.stack, 1, dir)
+ g.directory_stack.position = 1
+end
+
+---Clears directories from the history
+---@param from? number All entries >= this index are cleared.
+---@return string[]
+function directory_movement.clear_history(from)
+ ---@type string[]
+ local cleared = {}
+
+ from = from or 1
+ for i = g.history.size, from, -1 do
+ table.insert(cleared, g.history.list[i])
+ g.history.list[i] = nil
+ g.history.size = g.history.size - 1
+
+ if g.history.position >= i then
+ g.history.position = g.history.position - 1
+ end
+ end
+
+ return cleared
+end
+
+---Append a directory to the history
+---If we have navigated backward in the history,
+---then clear any history beyond the current point.
+---@param directory string
+function directory_movement.append_history(directory)
+ if g.history.list[g.history.position] == directory then
+ msg.debug('reloading same directory - history unchanged:', directory)
+ return
+ end
+
+ msg.debug('appending to history:', directory)
+ if g.history.position < g.history.size then
+ directory_movement.clear_history(g.history.position + 1)
+ end
+
+ table.insert(g.history.list, directory)
+ g.history.size = g.history.size + 1
+ g.history.position = g.history.position + 1
+
+ if g.history.size > o.history_size then
+ table.remove(g.history.list, 1)
+ g.history.size = g.history.size - 1
+ end
+end
+
+---@param filepath string
+function directory_movement.set_current_file(filepath)
+ --if we're in idle mode then we want to open the working directory
+ if filepath == nil then
+ g.current_file.directory = fb_utils.fix_path( mp.get_property("working-directory", ""), true)
+ g.current_file.name = nil
+ g.current_file.path = nil
+ g.current_file.original_path = nil
+ return
+ end
+
+ local absolute_path = fb_utils.absolute_path(filepath)
+ local resolved_path = fb_utils.resolve_directory_mapping(absolute_path)
+
+ g.current_file.directory, g.current_file.name = utils.split_path(resolved_path)
+ g.current_file.original_path = absolute_path
+ g.current_file.path = resolved_path
+
+ if o.cursor_follows_playing_item then cursor.select_playing_item() end
+ ass.update_ass()
+end
+
+--the base function for moving to a directory
+---@param directory string
+---@param nav_type? NavigationType
+---@param store_history? boolean default `true`
+---@param parse_properties? ParseProperties
+---@return thread
+function directory_movement.goto_directory(directory, nav_type, store_history, parse_properties)
+ local current = g.state.list[g.state.selected]
+ g.state.directory = directory
+
+ if g.state.directory_label then
+ if nav_type == NavType.DOWN then
+ g.state.directory_label = g.state.directory_label..(current.label or current.name)
+ elseif nav_type == NavType.UP then
+ g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$")
+ end
+ end
+
+ if o.history_size > 0 and store_history == nil or store_history then
+ directory_movement.append_history(directory)
+ end
+
+ return scanning.rescan(nav_type or NavType.GOTO, nil, parse_properties)
+end
+
+---Move the browser to a particular point in the browser history.
+---The history is a linear list of visited directories from oldest to newest.
+---If the user changes directories while the current history position is not the head of the list,
+---any later directories get cleared and the new directory becomes the new head.
+---@param pos number The history index to move to. Clamped to [1,history_length]
+---@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)
+function directory_movement.goto_history(pos)
+ if type(pos) ~= "number" then return false end
+
+ if pos < 1 then pos = 1
+ elseif pos > g.history.size then pos = g.history.size end
+ if not g.history.list[pos] then return false end
+
+ g.history.position = pos
+ directory_movement.goto_directory(g.history.list[pos])
+ return pos
+end
+
+--loads the root list
+function directory_movement.goto_root()
+ msg.verbose('jumping to root')
+ return directory_movement.goto_directory("")
+end
+
+--switches to the directory of the currently playing file
+function directory_movement.goto_current_dir()
+ msg.verbose('jumping to current directory')
+ return directory_movement.goto_directory(g.current_file.directory)
+end
+
+--moves up a directory
+function directory_movement.up_dir()
+ if g.state.directory == '' then return end
+
+ local cached_parent_dir = g.directory_stack.stack[g.directory_stack.position - 1]
+ if cached_parent_dir then
+ g.directory_stack.position = g.directory_stack.position - 1
+ return directory_movement.goto_directory(cached_parent_dir, NavType.UP)
+ end
+
+ local parent_dir = g.state.directory:match("^(.-/+)[^/]+/*$") or ""
+
+ if o.skip_protocol_schemes and parent_dir:find("^(%a[%w+-.]*)://$") then
+ return directory_movement.goto_root()
+ end
+
+ directory_stack_prepend(parent_dir)
+ return directory_movement.goto_directory(parent_dir, NavType.UP)
+end
+
+--moves down a directory
+function directory_movement.down_dir()
+ local current = g.state.list[g.state.selected]
+ if not current or not fb_utils.parseable_item(current) then return end
+
+ local directory, redirected = fb_utils.get_new_directory(current, g.state.directory)
+ directory_stack_append(directory)
+ return directory_movement.goto_directory(directory, redirected and NavType.REDIRECT or NavType.DOWN)
+end
+
+--moves backwards through the directory history
+function directory_movement.back_history()
+ msg.debug('moving backwards in history to', g.history.list[g.history.position-1])
+ if g.history.position == 1 then return end
+ directory_movement.goto_history(g.history.position - 1)
+end
+
+--moves forward through the history
+function directory_movement.forwards_history()
+ msg.debug('moving forwards in history to', g.history.list[g.history.position+1])
+ if g.history.position == g.history.size then return end
+ directory_movement.goto_history(g.history.position + 1)
+end
+
+return directory_movement
diff --git a/mpv/scripts/file-browser/modules/navigation/scanning.lua b/mpv/scripts/file-browser/modules/navigation/scanning.lua
new file mode 100644
index 0000000..0923ba9
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/navigation/scanning.lua
@@ -0,0 +1,210 @@
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local cursor = require 'modules.navigation.cursor'
+local ass = require 'modules.ass'
+
+local parse_state_API = require 'modules.apis.parse-state'
+
+---@class scanning
+local scanning = {}
+
+---@enum NavigationType
+local NavType = {
+ DOWN = 1,
+ UP = -1,
+ REDIRECT = 2,
+ GOTO = 3,
+ RESCAN = 4,
+}
+
+scanning.NavType = NavType
+
+---@param directory_stack? boolean
+local function clear_non_adjacent_state(directory_stack)
+ g.state.directory_label = nil
+ if directory_stack then
+ g.directory_stack.stack = {g.state.directory}
+ g.directory_stack.position = 1
+ end
+end
+
+---parses the given directory or defers to the next parser if nil is returned
+---@async
+---@param directory string
+---@param index number
+---@return List?
+---@return Opts?
+function scanning.choose_and_parse(directory, index)
+ msg.debug(("finding parser for %q"):format(directory))
+ ---@type Parser, List?, Opts?
+ local parser, list, opts
+ local parse_state = g.parse_states[coroutine.running() or ""]
+ while list == nil and not parse_state.already_deferred and index <= #g.parsers do
+ parser = g.parsers[index]
+ if parser:can_parse(directory, parse_state) then
+ msg.debug("attempting parser:", parser:get_id())
+ list, opts = parser:parse(directory, parse_state)
+ end
+ index = index + 1
+ end
+ if not list then return nil, {} end
+
+ msg.debug("list returned from:", parser:get_id())
+ opts = opts or {}
+ if list then opts.id = opts.id or parser:get_id() end
+ return list, opts
+end
+
+---Sets up the parse_state table and runs the parse operation.
+---@async
+---@param directory string
+---@param parse_state_template ParseStateTemplate
+---@return List|nil
+---@return Opts
+local function run_parse(directory, parse_state_template)
+ msg.verbose(("scanning files in %q"):format(directory))
+
+ ---@type ParseStateFields
+ local parse_state = {
+ source = parse_state_template.source,
+ directory = directory,
+ properties = parse_state_template.properties or {}
+ }
+
+ local co = coroutine.running()
+ g.parse_states[co] = fb_utils.set_prototype(parse_state, parse_state_API) --[[@as ParseState]]
+
+ local list, opts = scanning.choose_and_parse(directory, 1)
+
+ if list == nil then return msg.debug("no successful parsers found"), {} end
+ opts = opts or {}
+ opts.parser = g.parsers[opts.id]
+
+ if not opts.filtered then fb_utils.filter(list) end
+ if not opts.sorted then fb_utils.sort(list) end
+ return list, opts
+end
+
+---Returns the contents of the given directory using the given parse state.
+---If a coroutine has already been used for a parse then create a new coroutine so that
+---the every parse operation has a unique thread ID.
+---@async
+---@param directory string
+---@param parse_state ParseStateTemplate
+---@return List|nil
+---@return Opts
+function scanning.scan_directory(directory, parse_state)
+ local co = fb_utils.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state))
+ if not g.parse_states[co] then return run_parse(directory, parse_state) end
+
+ --if this coroutine is already is use by another parse operation then we create a new
+ --one and hand execution over to that
+ ---@async
+ local new_co = coroutine.create(function()
+ fb_utils.coroutine.resume_err(co, run_parse(directory, parse_state))
+ end)
+
+ --queue the new coroutine on the mpv event queue
+ mp.add_timeout(0, function()
+ local success, err = coroutine.resume(new_co)
+ if not success then
+ fb_utils.traceback(err, new_co)
+ fb_utils.coroutine.resume_err(co)
+ end
+ end)
+ return g.parse_states[co]:yield()
+end
+
+---Sends update requests to the different parsers.
+---@async
+---@param moving_adjacent? number|boolean
+---@param parse_properties? ParseProperties
+local function update_list(moving_adjacent, parse_properties)
+ msg.verbose('opening directory: ' .. g.state.directory)
+
+ g.state.selected = 1
+ g.state.selection = {}
+
+ local directory = g.state.directory
+ local list, opts = scanning.scan_directory(g.state.directory, { source = "browser", properties = parse_properties })
+
+ --if the running coroutine isn't the one stored in the state variable, then the user
+ --changed directories while the coroutine was paused, and this operation should be aborted
+ if coroutine.running() ~= g.state.co then
+ msg.verbose(g.ABORT_ERROR.msg)
+ msg.debug("expected:", g.state.directory, "received:", directory)
+ return
+ end
+
+ --apply fallbacks if the scan failed
+ if not list then
+ msg.warn("could not read directory", g.state.directory)
+ list, opts = {}, {}
+ opts.empty_text = g.style.warning..'Error: could not parse directory'
+ end
+
+ g.state.list = list
+ g.state.parser = opts.parser
+
+ --setting custom options from parsers
+ g.state.directory_label = opts.directory_label
+ g.state.empty_text = opts.empty_text or g.state.empty_text
+
+ --we assume that directory is only changed when redirecting to a different location
+ --therefore we need to change the `moving_adjacent` flag and clear some state values
+ if opts.directory then
+ g.state.directory = opts.directory
+ moving_adjacent = false
+ clear_non_adjacent_state(true)
+ end
+
+ if opts.selected_index then
+ g.state.selected = opts.selected_index or g.state.selected
+ if g.state.selected > #g.state.list then g.state.selected = #g.state.list
+ elseif g.state.selected < 1 then g.state.selected = 1 end
+ end
+
+ if moving_adjacent then cursor.select_prev_directory()
+ else cursor.select_playing_item() end
+ g.state.prev_directory = g.state.directory
+end
+
+---rescans the folder and updates the list.
+---@param nav_type? NavigationType
+---@param cb? function
+---@param parse_properties? ParseProperties
+---@return thread # The coroutine for the triggered parse operation. May be aborted early if directory is in the cache.
+function scanning.rescan(nav_type, cb, parse_properties)
+ if nav_type == nil then nav_type = NavType.RESCAN end
+
+ --we can only make assumptions about the directory label when moving from adjacent directories
+ if nav_type == NavType.GOTO or nav_type == NavType.REDIRECT then
+ clear_non_adjacent_state(nav_type == NavType.GOTO)
+ end
+
+ g.state.empty_text = "~"
+ g.state.list = {}
+ cursor.disable_select_mode()
+ ass.update_ass()
+
+ --the directory is always handled within a coroutine to allow addons to
+ --pause execution for asynchronous operations
+ ---@async
+ local co = fb_utils.coroutine.queue(function()
+ update_list(nav_type, parse_properties)
+ if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end
+
+ ass.update_ass()
+ if cb then fb_utils.coroutine.run(cb) end
+ end)
+
+ g.state.co = co
+ return co
+end
+
+
+return scanning
diff --git a/mpv/scripts/file-browser/modules/observers.lua b/mpv/scripts/file-browser/modules/observers.lua
new file mode 100644
index 0000000..77a9594
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/observers.lua
@@ -0,0 +1,48 @@
+local g = require 'modules.globals'
+local directory_movement = require 'modules.navigation.directory-movement'
+local fb = require 'modules.apis.fb'
+local fb_utils = require 'modules.utils'
+local ass = require 'modules.ass'
+
+---@class observers
+local observers ={}
+
+---saves the directory and name of the currently playing file
+---@param _ string
+---@param filepath string
+function observers.current_directory(_, filepath)
+ directory_movement.set_current_file(filepath)
+end
+
+---@param _ string
+---@param device string
+function observers.dvd_device(_, device)
+ if not device or device == "" then device = '/dev/dvd' end
+ fb.register_directory_mapping(fb_utils.absolute_path(device), '^dvd://.*', true)
+end
+
+---@param _ string
+---@param device string
+function observers.bd_device(_, device)
+ if not device or device == '' then device = '/dev/bd' end
+ fb.register_directory_mapping(fb_utils.absolute_path(device), '^bd://.*', true)
+end
+
+---@param _ string
+---@param device string
+function observers.cd_device(_, device)
+ if not device or device == '' then device = '/dev/cdrom' end
+ fb.register_directory_mapping(fb_utils.absolute_path(device), '^cdda://.*', true)
+end
+
+---@param property string
+---@param alignment string
+function observers.osd_align(property, alignment)
+ if property == 'osd-align-x' then g.ALIGN_X = alignment
+ elseif property == 'osd-align-y' then g.ALIGN_Y = alignment end
+
+ g.style.global = ([[{\an%d}]]):format(g.ASS_ALIGNMENT_MATRIX[g.ALIGN_Y][g.ALIGN_X])
+ ass.update_ass()
+end
+
+return observers
diff --git a/mpv/scripts/file-browser/modules/options.lua b/mpv/scripts/file-browser/modules/options.lua
new file mode 100644
index 0000000..a38670c
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/options.lua
@@ -0,0 +1,193 @@
+local utils = require 'mp.utils'
+local opt = require 'mp.options'
+
+---@class options
+local o = {
+ --root directories
+ root = "~/",
+
+ --automatically detect windows drives and adds them to the root.
+ auto_detect_windows_drives = true,
+
+ --characters to use as separators
+ root_separators = ",",
+
+ --number of entries to show on the screen at once
+ num_entries = 20,
+
+ --number of directories to keep in the history
+ history_size = 100,
+
+ --wrap the cursor around the top and bottom of the list
+ wrap = false,
+
+ --only show files compatible with mpv
+ filter_files = true,
+
+ --recurses directories concurrently when appending items to the playlist
+ concurrent_recursion = true,
+
+ --maximum number of recursions that can run concurrently
+ max_concurrency = 16,
+
+ --enable custom keybinds
+ custom_keybinds = true,
+ custom_keybinds_file = "~~/script-opts/file-browser-keybinds.json",
+
+ --blacklist compatible files, it's recommended to use this rather than to edit the
+ --compatible list directly. A comma separated list of extensions without spaces
+ extension_blacklist = "",
+
+ --add extra file extensions
+ extension_whitelist = "",
+
+ --files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
+ audio_extensions = "mka,dts,dtshd,dts-hd,truehd,true-hd",
+
+ --files with these extensions will be added as additional subtitle tracks instead of appended to the playlist
+ subtitle_extensions = "etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs",
+
+ --filter dot directories like .config
+ --most useful on linux systems
+ ---@type 'auto'|'yes'|'no'
+ filter_dot_dirs = 'auto',
+ ---@type 'auto'|'yes'|'no'
+ filter_dot_files = 'auto',
+
+ --substitute forward slashes for backslashes when appending a local file to the playlist
+ --potentially useful on windows systems
+ substitute_backslash = false,
+
+ --interpret backslashes `\` in paths as forward slashes `/`
+ --this is useful on Windows, which natively uses backslashes.
+ --As backslashes are valid filename characters in Unix systems this could
+ --cause mangled paths, though such filenames are rare.
+ --Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform`
+ --property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`.
+ ---@type 'auto'|'yes'|'no'
+ normalise_backslash = 'auto',
+
+ --a directory cache to improve directory reading time,
+ --enable if it takes a long time to load directories.
+ --may cause 'ghost' files to be shown that no-longer exist or
+ --fail to show files that have recently been created.
+ cache = false,
+
+ --this option reverses the behaviour of the alt+ENTER keybind
+ --when disabled the keybind is required to enable autoload for the file
+ --when enabled the keybind disables autoload for the file
+ autoload = false,
+
+ --if autoload is triggered by selecting the currently playing file, then
+ --the current file will have it's watch-later config saved before being closed
+ --essentially the current file will not be restarted
+ autoload_save_current = true,
+
+ --when opening the browser in idle mode prefer the current working directory over the root
+ --note that the working directory is set as the 'current' directory regardless, so `home` will
+ --move the browser there even if this option is set to false
+ default_to_working_directory = false,
+
+ --When opening the browser prefer the directory last opened by a previous mpv instance of file-browser.
+ --Overrides the `default_to_working_directory` option.
+ --Requires `save_last_opened_directory` to be true.
+ --Uses the internal `last-opened-directory` addon.
+ default_to_last_opened_directory = false,
+
+ --Whether to save the last opened directory and the file to save this value in.
+ save_last_opened_directory = false,
+ last_opened_directory_file = '~~state/file_browser-last_opened_directory',
+
+ --when moving up a directory do not stop on empty protocol schemes like `ftp://`
+ --e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://`
+ skip_protocol_schemes = true,
+
+ --move the cursor to the currently playing item (if available) when the playing file changes
+ cursor_follows_playing_item = false,
+
+ --Replace the user's home directory with `~/` in the header.
+ --Uses the internal home-label addon.
+ home_label = true,
+
+ --map optical device paths to their respective file paths,
+ --e.g. mapping bd:// to the value of the bluray-device property
+ map_bd_device = true,
+ map_dvd_device = true,
+ map_cdda_device = true,
+
+ --allows custom icons be set for the folder and cursor
+ --the `\h` character is a hard space to add padding between the symbol and the text
+ 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]],
+ 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]],
+ 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]],
+
+ --enable addons
+ addons = true,
+ addon_directory = "~~/script-modules/file-browser-addons",
+
+ --Enables the internal `ls` addon that parses directories using the `ls` commandline tool.
+ --Allows directory parsing to run concurrently, which prevents the browser from locking up.
+ --Automatically disables itself on Windows systems.
+ ls_parser = true,
+
+ --Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe.
+ --Allows directory parsing to run concurrently, which prevents the browser from locking up.
+ --Automatically disables itself on non-Windows systems.
+ windir_parser = true,
+
+ --directory to load external modules - currently just user-input-module
+ module_directory = "~~/script-modules",
+
+ --turn the OSC idle screen off and on when opening and closing the browser
+ toggle_idlescreen = false,
+
+ --Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
+ --This property is only available in mpv v0.36+.
+ set_user_data = true,
+
+ --Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
+ --This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically ignore this option.
+ set_shared_script_properties = false,
+
+ ---@type 'auto'|'left'|'center'|'right'
+ align_x = 'left',
+ ---@type 'auto'|'top'|'center'|'bottom'
+ align_y = 'top',
+
+ --style settings
+ format_string_header = [[{\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------]],
+ format_string_topwrapper = '...',
+ format_string_bottomwrapper = '...',
+
+ font_bold_header = true,
+ font_opacity_selection_marker = "99",
+
+ scaling_factor_base = 1,
+ scaling_factor_header = 1.4,
+ scaling_factor_wrappers = 1,
+
+ font_name_header = "",
+ font_name_body = "",
+ font_name_wrappers = "",
+ font_name_folder = "",
+ font_name_cursor = "",
+
+ font_colour_header = "00ccff",
+ font_colour_body = "ffffff",
+ font_colour_wrappers = "00ccff",
+ font_colour_cursor = "00ccff",
+ font_colour_escape_chars = "413eff",
+
+ font_colour_multiselect = "fcad88",
+ font_colour_selected = "fce788",
+ font_colour_playing = "33ff66",
+ font_colour_playing_multiselected = "22b547"
+
+}
+
+opt.read_options(o, 'file_browser')
+
+---@diagnostic disable-next-line deprecated
+o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set
+
+return o
diff --git a/mpv/scripts/file-browser/modules/playlist.lua b/mpv/scripts/file-browser/modules/playlist.lua
new file mode 100644
index 0000000..87b3d35
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/playlist.lua
@@ -0,0 +1,360 @@
+------------------------------------------------------------------------------------------
+---------------------------------File/Playlist Opening------------------------------------
+------------------------------------------------------------------------------------------
+------------------------------------------------------------------------------------------
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local ass = require 'modules.ass'
+local cursor = require 'modules.navigation.cursor'
+local controls = require 'modules.controls'
+local scanning = require 'modules.navigation.scanning'
+local movement = require 'modules.navigation.directory-movement'
+
+local state = g.state
+
+---@alias LoadfileFlag 'replace'|'append-play'
+
+---@class LoadOpts
+---@field directory string
+---@field flag LoadfileFlag
+---@field autoload boolean
+---@field items_appended number
+---@field co thread
+---@field concurrency number
+
+---In mpv v0.38 a new index argument was added to the loadfile command.
+---For some crazy reason this new argument is placed before the existing options
+---argument, breaking any scripts that used it. This function finds the correct index
+---for the options argument using the `command-list` property.
+---@return integer
+local function get_loadfile_options_arg_index()
+ ---@type table[]
+ local command_list = mp.get_property_native('command-list', {})
+ for _, command in ipairs(command_list) do
+ if command.name == 'loadfile' then
+ for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do
+ if arg.name == 'options' then
+ return i
+ end
+ end
+ end
+ end
+
+ return 3
+end
+
+local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3
+
+---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38.
+---@param file string
+---@param flag string
+---@param options? string|table<string,unknown>
+---@return boolean
+local function legacy_loadfile_wrapper(file, flag, options)
+ if LEGACY_LOADFILE_SYNTAX then
+ return mp.command_native({"loadfile", file, flag, options}) ~= nil
+ else
+ return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil
+ end
+end
+
+---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items.
+---@param file string
+---@param opts LoadOpts
+---@param mpv_opts? string|table<string,unknown>
+local function loadfile(file, opts, mpv_opts)
+ if o.substitute_backslash and not fb_utils.get_protocol(file) then
+ file = string.gsub(file, "/", "\\")
+ end
+
+ if opts.flag == "replace" then msg.verbose("Playling file", file)
+ else msg.verbose("Appending", file, "to the playlist") end
+
+ if mpv_opts then
+ msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts))
+ end
+
+ if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end
+ opts.flag = "append-play"
+ opts.items_appended = opts.items_appended + 1
+end
+
+---@diagnostic disable-next-line no-unknown
+local concurrent_loadlist_wrapper
+
+---@alias ConcurrentRefMap table<List|Item,{directory: string?, sublist: List?, recurse: boolean?}>
+
+---This function recursively loads directories concurrently in separate coroutines.
+---Results are saved in a tree of tables that allows asynchronous access.
+---@async
+---@param directory string
+---@param load_opts LoadOpts
+---@param prev_dirs Set<string>
+---@param item_t Item
+---@param refs ConcurrentRefMap
+---@return boolean?
+local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs)
+ if not refs[item_t] then refs[item_t] = {} end
+
+ --prevents infinite recursion from the item.path or opts.directory fields
+ if prev_dirs[directory] then return end
+ prev_dirs[directory] = true
+
+ local list, list_opts = scanning.scan_directory(directory, { source = 'loadlist' })
+ if list == g.root then return end
+
+ --if we can't parse the directory then append it and hope mpv fares better
+ if list == nil then
+ msg.warn("Could not parse", directory, "appending to playlist anyway")
+ refs[item_t].recurse = false
+ return
+ end
+
+ directory = list_opts.directory or directory
+
+ --we must declare these before we start loading sublists otherwise the append thread will
+ --need to wait until the whole list is loaded (when synchronous IO is used)
+ refs[item_t].sublist = list or {}
+ refs[list] = {directory = directory}
+
+ if directory == "" then return end
+
+ --launches new parse operations for directories, each in a different coroutine
+ for _, item in ipairs(list) do
+ if fb_utils.parseable_item(item) then
+ fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs)
+ end
+ end
+ return true
+end
+
+---A wrapper function that ensures the concurrent_loadlist_parse is run correctly.
+---@async
+---@param directory string
+---@param opts LoadOpts
+---@param prev_dirs Set<string>
+---@param item Item
+---@param refs ConcurrentRefMap
+function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs)
+ --ensures that only a set number of concurrent parses are operating at any one time.
+ --the mpv event queue is seemingly limited to 1000 items, but only async mpv actions like
+ --command_native_async should use that, events like mp.add_timeout (which coroutine.sleep() uses) should
+ --be handled enturely on the Lua side with a table, which has a significantly larger maximum size.
+ while (opts.concurrency > o.max_concurrency) do
+ fb_utils.coroutine.sleep(0.1)
+ end
+ opts.concurrency = opts.concurrency + 1
+
+ local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs)
+ opts.concurrency = opts.concurrency - 1
+ if not success then refs[item].sublist = {} end
+ if coroutine.status(opts.co) == "suspended" then fb_utils.coroutine.resume_err(opts.co) end
+end
+
+---Recursively appends items to the playlist, acts as a consumer to the previous functions producer;
+---If the next directory has not been parsed this function will yield until the parse has completed.
+---@async
+---@param list List
+---@param load_opts LoadOpts
+---@param refs ConcurrentRefMap
+local function concurrent_loadlist_append(list, load_opts, refs)
+ local directory = refs[list].directory
+
+ for _, item in ipairs(list) do
+ if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
+ and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
+ then
+ while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do
+ coroutine.yield()
+ end
+
+ if fb_utils.parseable_item(item) and refs[item] ~= false then
+ concurrent_loadlist_append(refs[item].sublist, load_opts, refs)
+ else
+ loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options)
+ end
+ end
+ end
+end
+
+---Recursive function to load directories serially.
+---Returns true if any items were appended to the playlist.
+---@async
+---@param directory string
+---@param load_opts LoadOpts
+---@param prev_dirs Set<string>
+---@return true|nil
+local function custom_loadlist_recursive(directory, load_opts, prev_dirs)
+ --prevents infinite recursion from the item.path or opts.directory fields
+ if prev_dirs[directory] then return end
+ prev_dirs[directory] = true
+
+ local list, opts = scanning.scan_directory(directory, { source = "loadlist" })
+ if list == g.root then return end
+
+ --if we can't parse the directory then append it and hope mpv fares better
+ if list == nil then
+ msg.warn("Could not parse", directory, "appending to playlist anyway")
+ loadfile(directory, load_opts)
+ return true
+ end
+
+ directory = opts.directory or directory
+ if directory == "" then return end
+
+ for _, item in ipairs(list) do
+ if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
+ and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
+ then
+ if fb_utils.parseable_item(item) then
+ custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs)
+ else
+ local path = fb_utils.get_full_path(item, directory)
+ loadfile(path, load_opts, item.mpv_options)
+ end
+ end
+ end
+end
+
+
+---A wrapper for the custom_loadlist_recursive function.
+---@async
+---@param item Item
+---@param opts LoadOpts
+local function loadlist(item, opts)
+ local dir = fb_utils.get_full_path(item, opts.directory)
+ local num_items = opts.items_appended
+
+ if o.concurrent_recursion then
+ item = fb_utils.copy_table(item)
+ opts.co = fb_utils.coroutine.assert()
+ opts.concurrency = 0
+
+ ---@type List
+ local v_list = {item}
+ ---@type ConcurrentRefMap
+ local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'})
+
+ --we need the current coroutine to suspend before we run the first parse operation, so
+ --we schedule the coroutine to run on the mpv event queue
+ fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs)
+ concurrent_loadlist_append(v_list, opts, refs)
+ else
+ custom_loadlist_recursive(dir, opts, {})
+ end
+
+ if opts.items_appended == num_items then msg.warn(dir, "contained no valid files") end
+end
+
+---Load playlist entries before and after the currently playing file.
+---@param path string
+---@param opts LoadOpts
+local function autoload_dir(path, opts)
+ if o.autoload_save_current and path == g.current_file.path then
+ mp.commandv("write-watch-later-config") end
+
+ --loads the currently selected file, clearing the playlist in the process
+ loadfile(path, opts)
+
+ local pos = 1
+ local file_count = 0
+ for _,item in ipairs(state.list) do
+ if item.type == "file"
+ and not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
+ and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
+ then
+ local p = fb_utils.get_full_path(item)
+
+ if p == path then pos = file_count
+ else loadfile( p, opts, item.mpv_options) end
+
+ file_count = file_count + 1
+ end
+ end
+ mp.commandv("playlist-move", 0, pos+1)
+end
+
+---Runs the loadfile or loadlist command.
+---@async
+---@param item Item
+---@param opts LoadOpts
+---@return nil
+local function open_item(item, opts)
+ if fb_utils.parseable_item(item) then
+ return loadlist(item, opts)
+ end
+
+ local path = fb_utils.get_full_path(item, opts.directory)
+ if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then
+ mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
+ elseif g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then
+ mp.commandv("audio-add", path, opts.flag == "replace" and "select" or "auto")
+ else
+ if opts.autoload then autoload_dir(path, opts)
+ else loadfile(path, opts, item.mpv_options) end
+ end
+end
+
+---Handles the open options as a coroutine.
+---Once loadfile has been run we can no-longer guarantee synchronous execution - the state values may change
+---therefore, we must ensure that any state values that could be used after a loadfile call are saved beforehand.
+---@async
+---@param opts LoadOpts
+---@return nil
+local function open_file_coroutine(opts)
+ if not state.list[state.selected] then return end
+ if opts.flag == 'replace' then controls.close() end
+
+ --we want to set the idle option to yes to ensure that if the first item
+ --fails to load then the player has a chance to attempt to load further items (for async append operations)
+ local idle = mp.get_property("idle", "once")
+ mp.set_property("idle", "yes")
+
+ --handles multi-selection behaviour
+ if next(state.selection) then
+ local selection = fb_utils.sort_keys(state.selection)
+ --reset the selection after
+ state.selection = {}
+
+ cursor.disable_select_mode()
+ ass.update_ass()
+
+ --the currently selected file will be loaded according to the flag
+ --the flag variable will be switched to append once a file is loaded
+ for i=1, #selection do
+ open_item(selection[i], opts)
+ end
+
+ else
+ local item = state.list[state.selected]
+ if opts.flag == "replace" then movement.down_dir() end
+ open_item(item, opts)
+ end
+
+ if mp.get_property("idle") == "yes" then mp.set_property("idle", idle) end
+end
+
+--opens the selelected file(s)
+local function open_file(flag, autoload)
+ ---@type LoadOpts
+ local opts = {
+ flag = flag,
+ autoload = (autoload ~= o.autoload and flag == "replace"),
+ directory = state.directory,
+ items_appended = 0,
+ concurrency = 0,
+ co = coroutine.create(open_file_coroutine)
+ }
+ fb_utils.coroutine.resume_err(opts.co, opts)
+end
+
+---@class playlist
+return {
+ add_files = open_file,
+}
diff --git a/mpv/scripts/file-browser/modules/script-messages.lua b/mpv/scripts/file-browser/modules/script-messages.lua
new file mode 100644
index 0000000..1c43961
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/script-messages.lua
@@ -0,0 +1,111 @@
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local scanning = require 'modules.navigation.scanning'
+
+---@class script_messages
+local script_messages = {}
+
+---Allows other scripts to request directory contents from file-browser.
+---@param directory string
+---@param response_str string
+function script_messages.get_directory_contents(directory, response_str)
+ ---@async
+ fb_utils.coroutine.run(function()
+ if not directory then msg.error("did not receive a directory string"); return end
+ if not response_str then msg.error("did not receive a response string"); return end
+
+ directory = mp.command_native({"expand-path", directory}, "") --[[@as string]]
+ if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
+ msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str))
+
+ directory = fb_utils.resolve_directory_mapping(directory)
+
+ ---@class OptsWithVersion: Opts
+ ---@field API_VERSION string?
+
+ ---@type List|nil, OptsWithVersion|Opts|nil
+ local list, opts = scanning.scan_directory(directory, { source = "script-message" } )
+ if opts then opts.API_VERSION = g.API_VERSION end
+
+ local list_str, err = fb_utils.format_json_safe(list)
+ if not list_str then msg.error(err) end
+
+ local opts_str, err2 = fb_utils.format_json_safe(opts)
+ if not opts_str then msg.error(err2) end
+
+ mp.commandv("script-message", response_str, list_str or "", opts_str or "")
+ end)
+end
+
+---A helper script message for custom keybinds.
+---Substitutes any '=>' arguments for 'script-message'.
+---Makes chaining script-messages much easier.
+---@param ... string
+function script_messages.chain(...)
+ ---@type string[]
+ local command = table.pack('script-message', ...)
+ for i, v in ipairs(command) do
+ if v == '=>' then command[i] = 'script-message' end
+ end
+ mp.commandv(table.unpack(command))
+end
+
+---A helper script message for custom keybinds.
+---Sends a command after the specified delay.
+---@param delay string
+---@param ... string
+---@return nil
+function script_messages.delay_command(delay, ...)
+ local command = table.pack(...)
+ local success, err = pcall(mp.add_timeout, fb_utils.evaluate_string('return '..delay), function() mp.commandv(table.unpack(command)) end)
+ if not success then return msg.error(err) end
+end
+
+---A helper script message for custom keybinds.
+---Sends a command only if the given expression returns true.
+---@param condition string
+---@param ... string
+function script_messages.conditional_command(condition, ...)
+ local command = table.pack(...)
+ fb_utils.coroutine.run(function()
+ if fb_utils.evaluate_string('return '..condition) == true then mp.commandv(table.unpack(command)) end
+ end)
+end
+
+---A helper script message for custom keybinds.
+---Extracts lua expressions from the command and evaluates them.
+---Expressions must be surrounded by !{}. Another ! before the { will escape the evaluation.
+---@param ... string
+function script_messages.evaluate_expressions(...)
+ ---@type string[]
+ local args = table.pack(...)
+ fb_utils.coroutine.run(function()
+ for i, arg in ipairs(args) do
+ args[i] = arg:gsub('(!+)(%b{})', function(lead, expression)
+ if #lead % 2 == 0 then return string.rep('!', #lead/2)..expression end
+
+ ---@type any
+ local eval = fb_utils.evaluate_string('return '..expression:sub(2, -2))
+ return type(eval) == "table" and utils.to_string(eval) or tostring(eval)
+ end)
+ end
+
+ mp.commandv(table.unpack(args))
+ end)
+end
+
+---A helper function for custom-keybinds.
+---Concatenates the command arguments with newlines and runs the
+---string as a statement of code.
+---@param ... string
+function script_messages.run_statement(...)
+ local statement = table.concat(table.pack(...), '\n')
+ fb_utils.coroutine.run(fb_utils.evaluate_string, statement)
+end
+
+return script_messages
diff --git a/mpv/scripts/file-browser/modules/setup.lua b/mpv/scripts/file-browser/modules/setup.lua
new file mode 100644
index 0000000..e68709d
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/setup.lua
@@ -0,0 +1,60 @@
+local mp = require 'mp'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+local fb_utils = require 'modules.utils'
+local fb = require 'modules.apis.fb'
+
+--sets up the compatible extensions list
+local function setup_extensions_list()
+ --setting up subtitle extensions
+ for ext in fb_utils.iterate_opt(o.subtitle_extensions:lower(), ',') do
+ g.sub_extensions[ext] = true
+ g.extensions[ext] = true
+ end
+
+ --setting up audio extensions
+ for ext in fb_utils.iterate_opt(o.audio_extensions:lower(), ',') do
+ g.audio_extensions[ext] = true
+ g.extensions[ext] = true
+ end
+
+ --adding file extensions to the set
+ for _, ext in ipairs(g.compatible_file_extensions) do
+ g.extensions[ext] = true
+ end
+
+ --adding extra extensions on the whitelist
+ for str in fb_utils.iterate_opt(o.extension_whitelist:lower(), ',') do
+ g.extensions[str] = true
+ end
+
+ --removing extensions that are in the blacklist
+ for str in fb_utils.iterate_opt(o.extension_blacklist:lower(), ',') do
+ g.extensions[str] = nil
+ end
+end
+
+--splits the string into a table on the separators
+local function setup_root()
+ for str in fb_utils.iterate_opt(o.root) do
+ local path = mp.command_native({'expand-path', str}) --[[@as string]]
+ path = fb_utils.fix_path(path, true)
+
+ local temp = {name = path, type = 'dir', label = str, ass = fb_utils.ass_escape(str, true)}
+
+ g.root[#g.root+1] = temp
+ end
+
+ if g.PLATFORM == 'windows' then
+ fb.register_root_item('C:/')
+ elseif g.PLATFORM ~= nil then
+ fb.register_root_item('/')
+ end
+end
+
+---@class setup
+return {
+ extensions_list = setup_extensions_list,
+ root = setup_root,
+}
diff --git a/mpv/scripts/file-browser/modules/utils.lua b/mpv/scripts/file-browser/modules/utils.lua
new file mode 100644
index 0000000..4985a53
--- /dev/null
+++ b/mpv/scripts/file-browser/modules/utils.lua
@@ -0,0 +1,637 @@
+--------------------------------------------------------------------------------------------------------
+-----------------------------------------Utility Functions----------------------------------------------
+---------------------------------------Part of the addon API--------------------------------------------
+--------------------------------------------------------------------------------------------------------
+
+local mp = require 'mp'
+local msg = require 'mp.msg'
+local utils = require 'mp.utils'
+
+local o = require 'modules.options'
+local g = require 'modules.globals'
+
+local input_loaded, input = pcall(require, 'mp.input')
+local user_input_loaded, user_input = pcall(require, 'user-input-module')
+
+--creates a table for the API functions
+--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
+---@class fb_utils
+local fb_utils = { API_VERSION = g.API_VERSION }
+
+fb_utils.list = {}
+fb_utils.coroutine = {}
+
+--implements table.pack if on lua 5.1
+if not table.pack then
+ table.unpack = unpack ---@diagnostic disable-line deprecated
+---@diagnostic disable-next-line: duplicate-set-field
+ function table.pack(...)
+ local t = {n = select("#", ...), ...}
+ return t
+ end
+end
+
+---Returns the index of the given item in the table.
+---Return -1 if item does not exist.
+---@generic T
+---@param t T[]
+---@param item T
+---@param from_index? number
+---@return integer
+function fb_utils.list.indexOf(t, item, from_index)
+ for i = from_index or 1, #t, 1 do
+ if t[i] == item then return i end
+ end
+ return -1
+end
+
+---Returns whether or not the given table contains an entry that
+---causes the given function to evaluate to true.
+---@generic T
+---@param t T[]
+---@param fn fun(v: T, i: number, t: T[]): boolean
+---@return boolean
+function fb_utils.list.some(t, fn)
+ for i, v in ipairs(t --[=[@as any[]]=]) do
+ if fn(v, i, t) then return true end
+ end
+ return false
+end
+
+---Creates a new table populated with the results of
+---calling a provided function on every element in t.
+---@generic T
+---@generic R
+---@param t T[]
+---@param fn fun(v: T, i: number, t: T[]): R
+---@return R[]
+function fb_utils.list.map(t, fn)
+ local new_t = {}
+ for i, v in ipairs(t --[=[@as any[]]=]) do
+ new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown
+ end
+ return new_t
+end
+
+---Prints an error message and a stack trace.
+---Can be passed directly to xpcall.
+---@param errmsg string
+---@param co? thread A coroutine to grab the stack trace from.
+function fb_utils.traceback(errmsg, co)
+ if co then
+ msg.warn(debug.traceback(co))
+ else
+ msg.warn(debug.traceback("", 2))
+ end
+ msg.error(errmsg)
+end
+
+---Returns a table that stores the given table t as the __index in its metatable.
+---Creates a prototypally inherited table.
+---@generic T: table
+---@param t T
+---@return T
+function fb_utils.redirect_table(t)
+ return setmetatable({}, { __index = t })
+end
+
+---Sets the given table `proto` as the `__index` field in table `t`s metatable.
+---@generic T: table
+---@param t T
+---@param proto table
+---@return T
+function fb_utils.set_prototype(t, proto)
+ return setmetatable(t, { __index = proto })
+end
+
+---Prints an error if a coroutine returns an error.
+---Unlike coroutine.resume_err this still returns the results of coroutine.resume().
+---@param ... any
+---@return boolean
+---@return ...
+function fb_utils.coroutine.resume_catch(...)
+ local returns = table.pack(coroutine.resume(...))
+ if not returns[1] and returns[2] ~= g.ABORT_ERROR then
+ fb_utils.traceback(returns[2], select(1, ...))
+ end
+ return table.unpack(returns, 1, returns.n)
+end
+
+---Resumes a coroutine and prints an error if it was not sucessful.
+---@param ... any
+---@return boolean
+function fb_utils.coroutine.resume_err(...)
+ local success, err = coroutine.resume(...)
+ if not success and err ~= g.ABORT_ERROR then
+ fb_utils.traceback(err, select(1, ...))
+ end
+ return success
+end
+
+---Throws an error if not run from within a coroutine.
+---In lua 5.1 there is only one return value which will be nil if run from the main thread.
+---In lua 5.2 main will be true if running from the main thread.
+---@param err any
+---@return thread
+function fb_utils.coroutine.assert(err)
+ local co, main = coroutine.running()
+ assert(not main and co, err or "error - function must be executed from within a coroutine")
+ return co
+end
+
+---Creates a callback function to resume the current coroutine with the given time limit.
+---If the time limit expires the coroutine will be resumed. The first return value will be true
+---if the callback was resumed within the time limit and false otherwise.
+---If time_limit is falsy then there will be no time limit and there will be no additional return value.
+---@param time_limit? number seconds
+---@return fun(...)
+function fb_utils.coroutine.callback(time_limit)
+ local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")
+ local timer = time_limit and mp.add_timeout(time_limit, function ()
+ msg.debug("time limit on callback expired")
+ fb_utils.coroutine.resume_err(co, false)
+ end)
+
+ local function fn(...)
+ if timer then
+ if not timer:is_enabled() then return
+ else timer:kill() end
+ return fb_utils.coroutine.resume_err(co, true, ...)
+ end
+ return fb_utils.coroutine.resume_err(co, ...)
+ end
+ return fn
+end
+
+---Puts the current coroutine to sleep for the given number of seconds.
+---@async
+---@param n number
+---@return nil
+function fb_utils.coroutine.sleep(n)
+ mp.add_timeout(n, fb_utils.coroutine.callback())
+ coroutine.yield()
+end
+
+---Runs the given function in a coroutine, passing through any additional arguments.
+---Does not run the coroutine immediately, instead it queues the coroutine to run when the thread is next idle.
+---Returns the coroutine object so that the caller can act on it before it is run.
+---@param fn async fun()
+---@param ... any
+---@return thread
+function fb_utils.coroutine.queue(fn, ...)
+ local co = coroutine.create(fn)
+ local args = table.pack(...)
+ mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end)
+ return co
+end
+
+---Runs the given function in a coroutine, passing through any additional arguments.
+---This is for triggering an event in a coroutine.
+---@param fn async fun()
+---@param ... any
+function fb_utils.coroutine.run(fn, ...)
+ local co = coroutine.create(fn)
+ fb_utils.coroutine.resume_err(co, ...)
+end
+
+---Get the full path for the current file.
+---@param item Item
+---@param dir? string
+---@return string
+function fb_utils.get_full_path(item, dir)
+ if item.path then return item.path end
+ return (dir or g.state.directory)..item.name
+end
+
+---Gets the path for a new subdirectory, redirects if the path field is set.
+---Returns the new directory path and a boolean specifying if a redirect happened.
+---@param item Item
+---@param directory string
+---@return string new_directory
+---@return boolean? redirected `true` if the path was redirected
+function fb_utils.get_new_directory(item, directory)
+ if item.path and item.redirect ~= false then return item.path, true end
+ if directory == "" then return item.name end
+ if string.sub(directory, -1) == "/" then return directory..item.name end
+ return directory.."/"..item.name
+end
+
+---Returns the file extension of the given file, or def if there is none.
+---@generic T
+---@param filename string
+---@param def? T
+---@return string|T
+---@overload fun(filename: string): string|nil
+function fb_utils.get_extension(filename, def)
+ return string.lower(filename):match("%.([^%./]+)$") or def
+end
+
+---Returns the protocol scheme of the given url, or def if there is none.
+---@generic T
+---@param filename string
+---@param def T
+---@return string|T
+---@overload fun(filename: string): string|nil
+function fb_utils.get_protocol(filename, def)
+ return string.lower(filename):match("^(%a[%w+-.]*)://") or def
+end
+
+---Formats strings for ass handling.
+---This function is based on a similar function from
+---https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110.
+---@param str string
+---@param replace_newline? true|string
+---@return string
+function fb_utils.ass_escape(str, replace_newline)
+ if replace_newline == true then replace_newline = "\\\239\187\191n" end
+
+ --escape the invalid single characters
+ str = string.gsub(str, '[\\{}\n]', {
+ -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
+ -- it isn't followed by a recognised character, so add a zero-width
+ -- non-breaking space
+ ['\\'] = '\\\239\187\191',
+ ['{'] = '\\{',
+ ['}'] = '\\}',
+ -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
+ -- consecutive newlines
+ ['\n'] = '\239\187\191\\N',
+ })
+
+ -- Turn leading spaces into hard spaces to prevent ASS from stripping them
+ str = str:gsub('\\N ', '\\N\\h')
+ str = str:gsub('^ ', '\\h')
+
+ if replace_newline then
+ str = string.gsub(str, "\\N", replace_newline)
+ end
+ return str
+end
+
+---Escape lua pattern characters.
+---@param str string
+---@return string
+function fb_utils.pattern_escape(str)
+ return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1"))
+end
+
+---Standardises filepaths across systems.
+---@param str string
+---@param is_directory? boolean
+---@return string
+function fb_utils.fix_path(str, is_directory)
+ if str == '' then return str end
+ if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then
+ str = string.gsub(str, [[\]],[[/]])
+ end
+ str = str:gsub([[/%./]], [[/]])
+ if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
+ return str
+end
+
+---Wrapper for mp.utils.join_path to handle protocols.
+---@param working string
+---@param relative string
+---@return string
+function fb_utils.join_path(working, relative)
+ return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative)
+end
+
+---Converts the given path into an absolute path and normalises it using fb_utils.fix_path.
+---@param path string
+---@return string
+function fb_utils.absolute_path(path)
+ local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path)
+ return fb_utils.fix_path(absolute_path)
+end
+
+---Sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes.
+---The number format functionality was proposed by github user twophyro, and was presumably taken
+---from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua.
+---@param t List
+---@return List
+function fb_utils.sort(t)
+ local function padnum(n, d)
+ return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
+ or ("%03d%s"):format(#n, n)
+ end
+
+ --appends the letter d or f to the start of the comparison to sort directories and folders as well
+ ---@type [string,Item][]
+ local tuples = {}
+ for i, f in ipairs(t) do
+ tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
+ end
+ table.sort(tuples, function(a, b)
+ -- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays
+ return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
+ end)
+ for i, tuple in ipairs(tuples) do t[i] = tuple[2] end
+ return t
+end
+
+---@param dir string
+---@return boolean
+function fb_utils.valid_dir(dir)
+ if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then
+ return string.sub(dir, 1, 1) ~= "."
+ end
+ return true
+end
+
+---@param file string
+---@return boolean
+function fb_utils.valid_file(file)
+ if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then
+ if string.sub(file, 1, 1) == "." then return false end
+ end
+ if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end
+ return true
+end
+
+---Returns whether or not the item can be parsed.
+---@param item Item
+---@return boolean
+function fb_utils.parseable_item(item)
+ return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")]
+end
+
+---Takes a directory string and resolves any directory mappings,
+---returning the resolved directory.
+---@param path string
+---@return string
+function fb_utils.resolve_directory_mapping(path)
+ if not path then return path end
+
+ for mapping, target in pairs(g.directory_mappings) do
+ local start, finish = string.find(path, mapping)
+ if start then
+ msg.debug('mapping', mapping, 'found for', path, 'changing to', target)
+
+ -- if the mapping is an exact match then return the target as is
+ if finish == #path then return target end
+
+ -- else make sure the path is correctly formatted
+ target = fb_utils.fix_path(target, true)
+ return (string.gsub(path, mapping, target))
+ end
+ end
+
+ return path
+end
+
+---Removes items and folders from the list that fail the configured filters.
+---@param t List
+---@return List
+function fb_utils.filter(t)
+ local max = #t
+ local top = 1
+ for i = 1, max do
+ local temp = t[i]
+ t[i] = nil
+
+ if ( temp.type == "dir" and fb_utils.valid_dir(temp.label or temp.name) ) or
+ ( temp.type == "file" and fb_utils.valid_file(temp.label or temp.name) )
+ then
+ t[top] = temp
+ top = top+1
+ end
+ end
+ return t
+end
+
+---Returns a string iterator that uses the root separators.
+---@param str any
+---@param separators? string Override the root separators.
+---@return fun():(string, ...)
+function fb_utils.iterate_opt(str, separators)
+ return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or o.root_separators).."]+)")
+end
+
+---Sorts a table into an array of selected items in the correct order.
+---If a predicate function is passed, then the item will only be added to
+---the table if the function returns true.
+---@param t Set<number>
+---@param include_item? fun(item: Item): boolean
+---@return Item[]
+function fb_utils.sort_keys(t, include_item)
+ ---@class Ref
+ ---@field item Item
+ ---@field index number
+
+ ---@type Ref[]
+ local keys = {}
+ for k in pairs(t) do
+ local item = g.state.list[k]
+ if not include_item or include_item(item) then
+ keys[#keys+1] = {
+ item = item,
+ index = k,
+ }
+ end
+ end
+
+ table.sort(keys, function(a,b) return a.index < b.index end)
+ return fb_utils.list.map(keys, function(ref) return ref.item end)
+end
+
+---Uses a loop to get the length of an array. The `#` operator is undefined if there
+---are gaps in the array, this ensures there are none as expected by the mpv node function.
+---@param t any[]
+---@return integer
+local function get_length(t)
+ local i = 1
+ while t[i] do i = i+1 end
+ return i - 1
+end
+
+---Recursively removes elements of the table which would cause
+---utils.format_json to throw an error.
+---@generic T
+---@param t T
+---@return T
+local function json_safe_recursive(t)
+ if type(t) ~= "table" then return t end
+
+ local array_length = get_length(t)
+ local isarray = array_length > 0
+
+ for key, value in pairs(t --[[@as table<any,any>]]) do
+ local ktype = type(key)
+ local vtype = type(value)
+
+ if vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread"
+ and (( isarray and ktype == "number" and key <= array_length)
+ or (not isarray and ktype == "string"))
+ then
+ ---@diagnostic disable-next-line no-unknown
+ t[key] = json_safe_recursive(t[key])
+ elseif key then
+ ---@diagnostic disable-next-line no-unknown
+ t[key] = nil
+ if isarray then array_length = get_length(t) end
+ end
+ end
+ return t
+end
+
+---Formats a table into a json string but ensures there are no invalid datatypes inside the table first.
+---@param t any
+---@return string|nil
+---@return string|nil err
+function fb_utils.format_json_safe(t)
+ --operate on a copy of the table to prevent any data loss in the original table
+ t = json_safe_recursive(fb_utils.copy_table(t))
+ local success, result, err = pcall(utils.format_json, t)
+ if success then return result, err
+ else return nil, result end
+end
+
+---Evaluates and runs the given string in both Lua 5.1 and 5.2.
+---Provides the mpv modules and the fb module to the string.
+---@param str string
+---@param chunkname? string Used for error reporting.
+---@param custom_env? table A custom environment that shadows the default environment.
+---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`.
+---@return unknown
+function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
+ ---@type table
+ local env
+ if env_defaults ~= false then
+ ---@type table
+ env = fb_utils.redirect_table(_G)
+ env.mp = fb_utils.redirect_table(mp)
+ env.msg = fb_utils.redirect_table(msg)
+ env.utils = fb_utils.redirect_table(utils)
+ env.fb = fb_utils.redirect_table(require 'file-browser')
+ env.input = input_loaded and fb_utils.redirect_table(input)
+ env.user_input = user_input_loaded and fb_utils.redirect_table(user_input)
+ env = fb_utils.set_prototype(custom_env or {}, env)
+ else
+ env = custom_env or {}
+ end
+
+ ---@type function, any
+ local chunk, err
+ if setfenv then ---@diagnostic disable-line deprecated
+ chunk, err = loadstring(str, chunkname) ---@diagnostic disable-line deprecated
+ if chunk then setfenv(chunk, env) end ---@diagnostic disable-line deprecated
+ else
+ chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter
+ end
+ if not chunk then
+ msg.warn('failed to load string:', str)
+ msg.error(err)
+ chunk = function() return nil end
+ end
+
+ return chunk()
+end
+
+---Copies a table without leaving any references to the original.
+---Uses a structured clone algorithm to maintain cyclic references.
+---@generic T
+---@param t T
+---@param references table<table,table>
+---@param depth number
+---@return T
+local function copy_table_recursive(t, references, depth)
+ if type(t) ~= "table" or depth == 0 then return t end
+ if references[t] then return references[t] end
+
+ local copy = setmetatable({}, { __original = t })
+ references[t] = copy
+
+ for key, value in pairs(t --[[@as table<any,any>]]) do
+ key = copy_table_recursive(key, references, depth - 1)
+ copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown
+ end
+ return copy
+end
+
+---A wrapper around copy_table to provide the reference table.
+---@generic T
+---@param t T
+---@param depth? number
+---@return T
+function fb_utils.copy_table(t, depth)
+ --this is to handle cyclic table references
+ return copy_table_recursive(t, {}, depth or math.huge)
+end
+
+---@alias Replacer fun(item: Item, s: State): (string|number|nil)
+---@alias ReplacerTable table<string,Replacer>
+
+---functions to replace custom-keybind codes
+---@type ReplacerTable
+fb_utils.code_fns = {
+ ["%"] = function() return "%" end,
+
+ f = function(item, s) return item and fb_utils.get_full_path(item, s.directory) or "" end,
+ n = function(item, s) return item and (item.label or item.name) or "" end,
+ i = function(item, s)
+ local i = fb_utils.list.indexOf(s.list, item)
+ if #s.list == 0 then return 0 end
+ return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0) ---@diagnostic disable-line deprecated
+ end,
+ j = function (item, s)
+ return fb_utils.list.indexOf(s.list, item) ~= -1 and math.abs(fb_utils.list.indexOf( fb_utils.sort_keys(s.selection) , item)) or 0
+ end,
+ x = function(_, s) return #s.list or 0 end,
+ p = function(_, s) return s.directory or "" end,
+ q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end,
+ d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end,
+ r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end,
+}
+
+---Programatically creates a pattern that matches any key code.
+---This will result in some duplicates but that shouldn't really matter.
+---@param codes ReplacerTable
+---@return string
+function fb_utils.get_code_pattern(codes)
+ ---@type string
+ local CUSTOM_KEYBIND_CODES = ""
+ for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
+ for key in pairs((getmetatable(codes) or {}).__index or {} --[[@as ReplacerTable]]) do
+ ---@type string
+ CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper()
+ end
+ return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES))
+end
+
+---Substitutes codes in the given string for other substrings.
+---@param str string
+---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters.
+---@param item? Item Uses the currently selected item if nil.
+---@param state? State Uses the global state if nil.
+---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string
+--- (the return value is the new replacement string).
+---@return string
+function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn)
+ local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns
+ item = item or g.state.list[g.state.selected]
+ state = state or g.state
+
+ return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code)
+ ---@type string|number|nil
+ local result
+ local replacer = replacers[code]
+
+ if type(replacer) == "string" then
+ result = replacer
+ --encapsulates the string if using an uppercase code
+ elseif not replacer then
+ local lower_fn = replacers[code:lower()]
+ if not lower_fn then return end
+ result = string.format("%q", lower_fn(item, state))
+ else
+ result = replacer(item, state)
+ end
+
+ if result and modifier_fn then return modifier_fn(tostring(result), code) end
+ return result
+ end))
+end
+
+
+return fb_utils