diff options
Diffstat (limited to 'mpv')
42 files changed, 6216 insertions, 0 deletions
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 |
