From d37987d9d4a56f2fab9b1841ecf8edb4c6c65a2b Mon Sep 17 00:00:00 2001 From: Adam <2327942-adamsdesk@users.noreply.gitlab.com> Date: Tue, 20 Sep 2022 17:22:26 -0600 Subject: [PATCH] feat: add mpv --- .../mpv/fonts/Material-Design-Iconic-Font.ttf | Bin 0 -> 99212 bytes mpv/.config/mpv/mpv.conf | 142 ++ mpv/.config/mpv/script-opts/file_browser.conf | 133 ++ mpv/.config/mpv/script-opts/osc.conf | 2 + .../mpv/script-opts/playlistmanager.conf | 147 ++ mpv/.config/mpv/scripts/file-browser.lua | 2082 ++++++++++++++++ mpv/.config/mpv/scripts/modern.lua | 2125 +++++++++++++++++ mpv/.config/mpv/scripts/playlistmanager.lua | 1125 +++++++++ 8 files changed, 5756 insertions(+) create mode 100644 mpv/.config/mpv/fonts/Material-Design-Iconic-Font.ttf create mode 100644 mpv/.config/mpv/mpv.conf create mode 100644 mpv/.config/mpv/script-opts/file_browser.conf create mode 100644 mpv/.config/mpv/script-opts/osc.conf create mode 100644 mpv/.config/mpv/script-opts/playlistmanager.conf create mode 100644 mpv/.config/mpv/scripts/file-browser.lua create mode 100644 mpv/.config/mpv/scripts/modern.lua create mode 100644 mpv/.config/mpv/scripts/playlistmanager.lua diff --git a/mpv/.config/mpv/fonts/Material-Design-Iconic-Font.ttf b/mpv/.config/mpv/fonts/Material-Design-Iconic-Font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5d489fdd1a04cf2169af5e4d29d2929432a73f04 GIT binary patch literal 99212 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 Bf0O_K literal 0 HcmV?d00001 diff --git a/mpv/.config/mpv/mpv.conf b/mpv/.config/mpv/mpv.conf new file mode 100644 index 0000000..5b633cd --- /dev/null +++ b/mpv/.config/mpv/mpv.conf @@ -0,0 +1,142 @@ +# +# Example mpv configuration file +# +# Warning: +# +# The commented example options usually do _not_ set the default values. Call +# mpv with --list-options to see the default values for most options. There is +# no builtin or example mpv.conf with all the defaults. +# +# +# Configuration files are read system-wide from /usr/local/etc/mpv.conf +# and per-user from ~/.config/mpv/mpv.conf, where per-user settings override +# system-wide settings, all of which are overridden by the command line. +# +# Configuration file settings and the command line options use the same +# underlying mechanisms. Most options can be put into the configuration file +# by dropping the preceding '--'. See the man page for a complete list of +# options. +# +# Lines starting with '#' are comments and are ignored. +# +# See the CONFIGURATION FILES section in the man page +# for a detailed description of the syntax. +# +# Profiles should be placed at the bottom of the configuration file to ensure +# that settings wanted as defaults are not restricted to specific profiles. + +################## +# video settings # +################## + +# Start in fullscreen mode by default. +#fs=yes + +# force starting with centered window +#geometry=50%:50% + +# don't allow a new window to have a size larger than 90% of the screen size +#autofit-larger=90%x90% + +# Do not close the window on exit. +#keep-open=yes + +# Do not wait with showing the video window until it has loaded. (This will +# resize the window once video is loaded. Also always shows a window with +# audio.) +#force-window=immediate + +# Disable the On Screen Controller (OSC). +osc=no + +# Keep the player window on top of all other windows. +#ontop=yes + +# Specify high quality video rendering preset (for --vo=gpu only) +# Can cause performance problems with some drivers and GPUs. +#profile=gpu-hq + +# Force video to lock on the display's refresh rate, and change video and audio +# speed to some degree to ensure synchronous playback - can cause problems +# with some drivers and desktop environments. +#video-sync=display-resample + +# Enable hardware decoding if available. Often, this does not work with all +# video outputs, but should work well with default settings on most systems. +# If performance or energy usage is an issue, forcing the vdpau or vaapi VOs +# may or may not help. +#hwdec=auto + +################## +# audio settings # +################## + +# Specify default audio device. You can list devices with: --audio-device=help +# The option takes the device string (the stuff between the '...'). +#audio-device=alsa/default + +# Do not filter audio to keep pitch when changing playback speed. +#audio-pitch-correction=no + +# Output 5.1 audio natively, and upmix/downmix audio with a different format. +#audio-channels=5.1 +# Disable any automatic remix, _if_ the audio output accepts the audio format. +# of the currently played file. See caveats mentioned in the manpage. +# (The default is "auto-safe", see manpage.) +#audio-channels=auto + +################## +# other settings # +################## + +# Pretend to be a web browser. Might fix playback with some streaming sites, +# but also will break with shoutcast streams. +#user-agent="Mozilla/5.0" + +# cache settings +# +# Use a large seekable RAM cache even for local input. +#cache=yes +# +# Use extra large RAM cache (needs cache=yes to make it useful). +#demuxer-max-bytes=500M +#demuxer-max-back-bytes=100M +# +# Disable the behavior that the player will pause if the cache goes below a +# certain fill size. +#cache-pause=no +# +# Store cache payload on the hard disk instead of in RAM. (This may negatively +# impact performance unless used for slow input such as network.) +#cache-dir=~/.cache/ +#cache-on-disk=yes + +# Display English subtitles if available. +#slang=en + +# Play Finnish audio if available, fall back to English otherwise. +#alang=fi,en + +# Change subtitle encoding. For Arabic subtitles use 'cp1256'. +# If the file seems to be valid UTF-8, prefer UTF-8. +# (You can add '+' in front of the codepage to force it.) +#sub-codepage=cp1256 + +# You can also include other configuration files. +#include=/path/to/the/file/you/want/to/include + +############ +# Profiles # +############ + +# The options declared as part of profiles override global default settings, +# but only take effect when the profile is active. + +# The following profile can be enabled on the command line with: --profile=eye-cancer + +#[eye-cancer] +#sharpen=5 + +no-audio-display +save-position-on-quit=yes +ontop diff --git a/mpv/.config/mpv/script-opts/file_browser.conf b/mpv/.config/mpv/script-opts/file_browser.conf new file mode 100644 index 0000000..f7f4e38 --- /dev/null +++ b/mpv/.config/mpv/script-opts/file_browser.conf @@ -0,0 +1,133 @@ +####################################################### +# This is the default config file for mpv-file-browser +# https://github.com/CogentRedTester/mpv-file-browser +####################################################### + +#root directories, separated by commas +#on linux you will probably want to add `/`, +#on windows this should be used to add different drive letters +#Examples: +#linux: root=~/,/ +#windows: root=~/,C:/ +root=~/ + +#characters to separate root directories, each character works individually +#this is in case one is using directories with strange names +root_separators=,; + +#number of entries to show on the screen at once +num_entries=20 + +#wrap the cursor around the top and bottom of the list +wrap=no + +#only show files compatible with mpv in the browser +filter_files=yes + +#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=no + +#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 + +#enable custom keybinds +#the keybind json file must go in ~~/script-opts +custom_keybinds=no + +#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 the root separators, 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 +filter_dot_dirs=no +filter_dot_files=no + +#substitude forward slashes for backslashes when appending a local file to the playlist +#may be useful on windows systems +substitute_backslash=no + +#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 + +#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 + +#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 + +#enables addons +addons=no +addon_directory=~~/script-modules/file-browser-addons + +#directory to load external modules - currently just user-input-module +module_directory=~~/script-modules + +#################################### +######### style settings ########### +#################################### + +#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 +#set to 0 to use the default mpv osd-align options +alignment=7 + +#allows custom icons be set to fix incompatabilities with some fonts +#the `\h` character is a hard space to add padding +folder_icon=🖿 +cursor_icon=➤ +indent_icon=\h\h\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 + +#set custom font sizes +font_size_header=35 +font_size_body=25 +font_size_wrappers=16 + +#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 most RGB colour codes +font_colour_header=00ccff +font_colour_body=ffffff +font_colour_wrappers=00ccff +font_colour_cursor=00ccff + +#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/.config/mpv/script-opts/osc.conf b/mpv/.config/mpv/script-opts/osc.conf new file mode 100644 index 0000000..0c6a673 --- /dev/null +++ b/mpv/.config/mpv/script-opts/osc.conf @@ -0,0 +1,2 @@ +vidscale=yes +volumecontrol=yes diff --git a/mpv/.config/mpv/script-opts/playlistmanager.conf b/mpv/.config/mpv/script-opts/playlistmanager.conf new file mode 100644 index 0000000..6b04d71 --- /dev/null +++ b/mpv/.config/mpv/script-opts/playlistmanager.conf @@ -0,0 +1,147 @@ +#### ------- Mpv-Playlistmanager configuration ------- #### + +#### ------- FUNCTIONAL ------- #### + +#navigation keybindings force override only while playlist is visible +#if "no" then you can display the playlist by any of the navigation keys +dynamic_binds=yes + +# main key +key_showplaylist=SHIFT+ENTER + +#dynamic keys - to bind multiple keys separate them by a space +key_moveup=UP +key_movedown=DOWN +key_movepageup=PGUP +key_movepagedown=PGDWN +key_movebegin=HOME +key_moveend=END +key_selectfile=RIGHT LEFT +key_unselectfile= +key_playfile=ENTER +key_removefile=BS +key_closeplaylist=ESC + +# extra functionality keys +key_sortplaylist= +key_shuffleplaylist= +key_reverseplaylist= +key_loadfiles= +key_saveplaylist= + +#json format for replacing, check .lua for explanation +#example json=[{"ext":{"all":true},"rules":[{"_":" "}]},{"ext":{"mp4":true,"mkv":true},"rules":[{"^(.+)%..+$":"%1"},{"%s*[%[%(].-[%]%)]%s*":""},{"(%w)%.(%w)":"%1 %2"}]},{"protocol":{"http":true,"https":true},"rules":[{"^%a+://w*%.?":""}]}] +#empty for no replace +filename_replace= + +#filetypes to search from directory +loadfiles_filetypes=["jpg","jpeg","png","tif","tiff","gif","webp","svg","bmp","mp3","wav","ogm","flac","m4a","wma","ogg","opus","mkv","avi","mp4","ogv","webm","rmvb","flv","wmv","mpeg","mpg","m4v","3gp"] + +#loadfiles at startup if 1 or more items in playlist +loadfiles_on_start=no +#loadfiles from working directory on idle startup +loadfiles_on_idle_start=no +#always put loaded files after currently playing file +loadfiles_always_append=no + +#sort playlist on mpv start +sortplaylist_on_start=no + +#sort playlist when any files are added to playlist after initial load +sortplaylist_on_file_add=no + +#yes: use alphanumerical sort comparison(nonpadded numbers in order), no: use normal lua string comparison +alphanumsort=yes + +#linux | windows | auto +system=auto + +#Use ~ for home directory. Leave as empty to use mpv/playlists +playlist_savepath= + +#save playlist automatically after current file was unloaded +save_playlist_on_file_end=no + +#2 shows playlist, 1 shows current file(filename strip applied), 0 shows nothing +show_playlist_on_fileload=0 + +#sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.) +sync_cursor_on_load=yes + +#playlist open key will toggle visibility instead of refresh +open_toggles=yes + +#allow the playlist cursor to loop from end to start and vice versa +loop_cursor=yes + +#youtube-dl executable for title resolving if enabled, probably "youtube-dl" or "yt-dlp", can be absolute path +youtube_dl_executable=youtube-dl + +#### ------- VISUAL ------- #### + +#prefer to display titles for following files: "all", "url", "none". Sorting still uses filename +prefer_titles=url + +#call youtube-dl to resolve the titles of urls in the playlist +resolve_titles=no + +#timeout in seconds for title resolving +resolve_title_timeout=15 + +#playlist timeout on inactivity, with high value on this open_toggles is good to be yes +playlist_display_timeout=5 + +#amount of entries to show before slicing. Optimal value depends on font/video size etc. +showamount=16 + +#font size scales by window, if no then needs larger font and padding sizes +scale_playlist_by_window=yes +#playlist ass style overrides +#example {\fnUbuntu\fs10\b0\bord1} equals: font=Ubuntu, size=10, bold=no, border=1 +#read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags +#no values defaults to OSD settings in mpv.conf +style_ass_tags= +#paddings for top left corner +text_padding_x=10 +text_padding_y=30 + +#set title of window with stripped name +set_title_stripped=no +title_prefix= +title_suffix= - mpv + +#slice long filenames, and how many chars to show +slice_longfilenames=no +slice_longfilenames_amount=70 + +#Playing header. One newline will be added after the string. +#%mediatitle or %filename = title or name of playing file +#%pos = position of playing file +#%cursor = position of navigation +#%plen = playlist lenght +#%N = newline +playlist_header=[%cursor/%plen] + +#Playlist file templates +#%pos = position of file with leading zeros +#%name = title or name of file +#%N = newline +#you can also use the ass tags mentioned above. For example: +# selected_file={\c&HFF00FF&}➔ %name | to add a color for selected file. However, if you +# use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20) +normal_file=○ %name +hovered_file=● %name +selected_file=➔ %name +playing_file=▷ %name +playing_hovered_file=▶ %name +playing_selected_file=➤ %name + +#what to show when playlist is truncated +playlist_sliced_prefix=... +playlist_sliced_suffix=... + +#output visual feedback to OSD when saving, shuffling, reversing playlists +display_osd_feedback=yes + +#reset cursor navigation when playlist is not visible +reset_cursor_on_close=yes diff --git a/mpv/.config/mpv/scripts/file-browser.lua b/mpv/.config/mpv/scripts/file-browser.lua new file mode 100644 index 0000000..6ba98c5 --- /dev/null +++ b/mpv/.config/mpv/scripts/file-browser.lua @@ -0,0 +1,2082 @@ +--[[ + 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 msg = require 'mp.msg' +local utils = require 'mp.utils' +local opt = require 'mp.options' + +local o = { + --root directories + root = "~/", + + --characters to use as separators + root_separators = ",;", + + --number of entries to show on the screen at once + num_entries = 20, + + --wrap the cursor around the top and bottom of the list + wrap = false, + + --only show files compatible with mpv + filter_files = true, + + --experimental feature that recurses directories concurrently when + --appending items to the playlist + concurrent_recursion = false, + + --maximum number of recursions that can run concurrently + max_concurrency = 16, + + --enable custom keybinds + custom_keybinds = false, + + --blacklist compatible files, it's recommended to use this rather than to edit the + --compatible list directly. A semicolon 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 + filter_dot_dirs = false, + filter_dot_files = false, + + --substitude forward slashes for backslashes when appending a local file to the playlist + --potentially useful on windows systems + substitute_backslash = 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, + + --allows custom icons be set to fix incompatabilities with some fonts + --the `\h` character is a hard space to add padding between the symbol and the text + folder_icon = "🖿", + cursor_icon = "➤", + indent_icon = [[\h\h\h]], + + --enable addons + addons = false, + addon_directory = "~~/script-modules/file-browser-addons", + + --directory to load external modules - currently just user-input-module + module_directory = "~~/script-modules", + + --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 + --set to 0 to use the default mpv osd-align options + alignment = 7, + + --style settings + font_bold_header = true, + font_opacity_selection_marker = "99", + + font_size_header = 35, + font_size_body = 25, + font_size_wrappers = 16, + + 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_multiselect = "fcad88", + font_colour_selected = "fce788", + font_colour_playing = "33ff66", + font_colour_playing_multiselected = "22b547" + +} + +opt.read_options(o, 'file_browser') +utils.shared_script_property_set("file_browser-open", "no") + + + +-------------------------------------------------------------------------------------------------------- +-----------------------------------------Environment Setup---------------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--sets the version for the file-browser API +API_VERSION = "1.3.0" + +--switch the main script to a different environment so that the +--executed lua code cannot access our global variales +if setfenv then + setfenv(1, setmetatable({}, { __index = _G })) +else + _ENV = setmetatable({}, { __index = _G }) +end + +--creates a table for the API functions +--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser +local API = { API_VERSION = API_VERSION } +package.loaded["file-browser"] = setmetatable({}, { __index = API }) + +local parser_API = setmetatable({}, { __index = package.loaded["file-browser"] }) +local parse_state_API = {} + +-------------------------------------------------------------------------------------------------------- +------------------------------------------Variable Setup------------------------------------------------ +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30 +local ass = mp.create_osd_overlay("ass-events") +if not ass then return msg.error("Script requires minimum mpv version 0.31") end + +package.path = mp.command_native({"expand-path", o.module_directory}).."/?.lua;"..package.path + +local style = { + global = o.alignment == 0 and "" or ([[{\an%d}]]):format(o.alignment), + + -- 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.font_size_header, o.font_name_header, o.font_colour_header), + body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.font_size_body, o.font_name_body, o.font_colour_body), + footer_header = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.font_size_wrappers, 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), + + --icon styles + 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), +} + +local state = { + list = {}, + selected = 1, + hidden = true, + flag_update = false, + keybinds = nil, + + parser = nil, + directory = nil, + directory_label = nil, + prev_directory = "", + co = nil, + + multiselect_start = nil, + initial_selection = nil, + selection = {} +} + +--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 } +local 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 +local parse_states = setmetatable({}, { __mode = "k"}) + +local extensions = {} +local sub_extensions = {} +local audio_extensions = {} +local parseable_extensions = {} + +local dvd_device = nil +local current_file = { + directory = nil, + name = nil, + path = nil +} + +local root = nil + +--default list of compatible file extensions +--adding an item to this list is a valid request on github +local 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" +} + +-------------------------------------------------------------------------------------------------------- +--------------------------------------Cache Implementation---------------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--metatable of methods to manage the cache +local __cache = {} + +__cache.cached_values = { + "directory", "directory_label", "list", "selected", "selection", "parser", "empty_text", "co" +} + +--inserts latest state values onto the cache stack +function __cache:push() + local t = {} + for _, value in ipairs(self.cached_values) do + t[value] = state[value] + end + table.insert(self, t) +end + +function __cache:pop() + table.remove(self) +end + +function __cache:apply() + local t = self[#self] + for _, value in ipairs(self.cached_values) do + state[value] = t[value] + end +end + +function __cache:clear() + for i = 1, #self do + self[i] = nil + end +end + +local cache = setmetatable({}, { __index = __cache }) + + + +-------------------------------------------------------------------------------------------------------- +-----------------------------------------Utility Functions---------------------------------------------- +---------------------------------------Part of the addon API-------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +API.coroutine = {} +local ABORT_ERROR = { + msg = "browser is no longer waiting for list - aborting parse" +} + +--implements table.pack if on lua 5.1 +if not table.pack then + table.unpack = unpack + function table.pack(...) + local t = {...} + t.n = select("#", ...) + return t + end +end + +--prints an error message and a stack trace +--accepts an error object and optionally a coroutine +--can be passed directly to xpcall +function API.traceback(errmsg, co) + if co then + msg.warn(debug.traceback(co)) + else + msg.warn(debug.traceback("", 2)) + end + msg.error(errmsg) +end + +--prints an error if a coroutine returns an error +--unlike the next function this one still returns the results of coroutine.resume() +function API.coroutine.resume_catch(...) + local returns = table.pack(coroutine.resume(...)) + if not returns[1] and returns[2] ~= ABORT_ERROR then + API.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 +function API.coroutine.resume_err(...) + local success, err = coroutine.resume(...) + if not success and err ~= ABORT_ERROR then + API.traceback(err, select(1, ...)) + end + return success +end + +--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 +function API.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 fuction to resume the current coroutine +function API.coroutine.callback() + local co = API.coroutine.assert("cannot create a coroutine callback for the main thread") + return function(...) + return API.coroutine.resume_err(co, ...) + end +end + +--puts the current coroutine to sleep for the given number of seconds +function API.coroutine.sleep(n) + mp.add_timeout(n, API.coroutine.callback()) + coroutine.yield() +end + +--runs the given function in a coroutine, passing through any additional arguments +--this is for triggering an event in a coroutine +function API.coroutine.run(fn, ...) + local co = coroutine.create(fn) + API.coroutine.resume_err(co, ...) +end + +--get the full path for the current file +function API.get_full_path(item, dir) + if item.path then return item.path end + return (dir or 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 +function API.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 +function API.get_extension(filename, def) + return string.lower(filename):match("%.([^%./]+)$") or def +end + +--returns the protocol scheme of the given url, or nil if there is none +function API.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 +function API.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 = str:gsub("\\N", replace_newline) + end + return str +end + +--escape lua pattern characters +function API.pattern_escape(str) + return string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1") +end + +--standardises filepaths across systems +function API.fix_path(str, is_directory) + str = string.gsub(str, [[\]],[[/]]) + str = str:gsub([[/./]], [[/]]) + if is_directory and str:sub(-1) ~= '/' then str = str..'/' end + return str +end + +--wrapper for utils.join_path to handle protocols +function API.join_path(working, relative) + return API.get_protocol(relative) and relative or utils.join_path(working, relative) +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 +function API.sort(t) + local function padnum(d) + local r = string.match(d, "0*(.+)") + return ("%03d%s"):format(#r, r) + end + + --appends the letter d or f to the start of the comparison to sort directories and folders as well + table.sort(t, function(a,b) return a.type:sub(1,1)..(a.label or a.name):lower():gsub("%d+",padnum) < b.type:sub(1,1)..(b.label or b.name):lower():gsub("%d+",padnum) end) + return t +end + +function API.valid_dir(dir) + if o.filter_dot_dirs and string.sub(dir, 1, 1) == "." then return false end + return true +end + +function API.valid_file(file) + if o.filter_dot_files and (string.sub(file, 1, 1) == ".") then return false end + if o.filter_files and not extensions[ API.get_extension(file, "") ] then return false end + return true +end + +--returns whether or not the item can be parsed +function API.parseable_item(item) + return item.type == "dir" or parseable_extensions[API.get_extension(item.name, "")] +end + +--removes items and folders from the list +--this is for addons which can't filter things during their normal processing +function API.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 API.valid_dir(temp.label or temp.name) ) or + ( temp.type == "file" and API.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 +function API.iterate_opt(str) + return string.gmatch(str, "([^"..API.pattern_escape(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 +function API.sort_keys(t, include_item) + local keys = {} + for k in pairs(t) do + local item = state.list[k] + if not include_item or include_item(item) then + item.index = k + keys[#keys+1] = item + end + end + + table.sort(keys, function(a,b) return a.index < b.index end) + return keys +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. +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 +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) 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 + t[key] = json_safe_recursive(t[key]) + elseif key then + 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 +function API.format_json_safe(t) + --operate on a copy of the table to prevent any data loss in the original table + t = json_safe_recursive(API.copy_table(t)) + local success, result, err = pcall(utils.format_json, t) + if success then return result, err + else return nil, result end +end + +--copies a table without leaving any references to the original +--uses a structured clone algorithm to maintain cyclic references +local function copy_table_recursive(t, references) + if type(t) ~= "table" then return t end + if references[t] then return references[t] end + + local mt = { + __original = t, + __index = getmetatable(t) + } + local copy = setmetatable({}, mt) + references[t] = copy + + for key, value in pairs(t) do + key = copy_table_recursive(key, references) + copy[key] = copy_table_recursive(value, references) + end + return copy +end + +--a wrapper around copy_table to provide the reference table +function API.copy_table(t) + --this is to handle cyclic table references + return copy_table_recursive(t, {}) +end + + + +-------------------------------------------------------------------------------------------------------- +------------------------------------Parser Object Implementation---------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--parser object for the root +--this object is not added to the parsers table so that scripts cannot get access to +--the root table, which is returned directly by parse() +local root_parser = { + name = "root", + priority = math.huge, + + --if this is being called then all other parsers have failed and we've fallen back to root + can_parse = function() return true end, + + --we return the root directory exactly as setup + parse = function(self) + return root, { + sorted = true, + filtered = true, + escaped = true, + parser = self, + directory = "", + } + end +} + +--parser ofject for native filesystems +local file_parser = { + name = "file", + priority = 110, + + --as the default parser we'll always attempt to use it if all others fail + can_parse = function(_, directory) return true end, + + --scans the given directory using the mp.utils.readdir function + parse = function(self, 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] + + --filters hidden dot directories for linux + if self.valid_dir(item) then + msg.trace(item..'/') + table.insert(new_list, {name = item..'/', type = 'dir'}) + end + end + + --appends files to the list of directory items + local list2 = utils.readdir(directory, 'files') + for i=1, #list2 do + local item = list2[i] + + --only adds whitelisted files to the browser + if self.valid_file(item) then + msg.trace(item) + table.insert(new_list, {name = item, type = 'file'}) + end + end + return API.sort(new_list), {filtered = true, sorted = true} + end +} + + + +-------------------------------------------------------------------------------------------------------- +-----------------------------------------List Formatting------------------------------------------------ +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--appends the entered text to the overlay +local function append(text) + if text == nil then return end + ass.data = ass.data .. text +end + +--appends a newline character to the osd +local function newline() +ass.data = ass.data .. '\\N' +end + +--detects whether or not to highlight the given entry as being played +local function highlight_entry(v) + if current_file.name == nil then return false end + if API.parseable_item(v) then + return current_file.directory:find(API.get_full_path(v), 1, true) + else + return current_file.path == API.get_full_path(v) + end +end + +--saves the directory and name of the currently playing file +local function update_current_directory(_, filepath) + --if we're in idle mode then we want to open the working directory + if filepath == nil then + current_file.directory = API.fix_path( mp.get_property("working-directory", ""), true) + current_file.name = nil + current_file.path = nil + return + elseif filepath:find("dvd://") == 1 then + filepath = dvd_device..filepath:match("dvd://(.*)") + end + + local workingDirectory = mp.get_property('working-directory', '') + local exact_path = API.join_path(workingDirectory, filepath) + exact_path = API.fix_path(exact_path, false) + current_file.directory, current_file.name = utils.split_path(exact_path) + current_file.path = exact_path +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 + + ass.data = style.global + + local dir_name = state.directory_label or state.directory + if dir_name == "" then dir_name = "ROOT" end + append(style.header) + append(API.ass_escape(dir_name, style.cursor.."\\\239\187\191n"..style.header)) + append('\\N ----------------------------------------------------') + newline() + + if #state.list < 1 then + append(state.empty_text) + ass:update() + return + end + + local start = 1 + 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 + 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 + + --adding a header to show there are items above in the list + if start > 1 then append(style.footer_header..(start-1)..' item(s) above\\N\\N') end + + for i=start, finish do + local v = state.list[i] + local playing_file = highlight_entry(v) + append(style.body) + + --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 + append(o.cursor_icon.."\\h"..style.body) + else + append(o.indent_icon.."\\h"..style.body) + end + + --sets the selection colour scheme + local multiselected = state.selection[i] + if multiselected then append(style.multiselect) + elseif i == state.selected then append(style.selected) end + + --prints the currently-playing icon and style + if playing_file and multiselected then append(style.playing_selected) + elseif playing_file then append(style.playing) end + + --sets the folder icon + if v.type == 'dir' then append(style.folder..o.folder_icon.."\\h".."{\\fn"..o.font_name_body.."}") end + + --adds the actual name of the item + append(v.ass or API.ass_escape(v.label or v.name, true)) + newline() + end + + if overflow then append('\\N'..style.footer_header..#state.list-finish..' item(s) remaining') end + ass:update() +end + + + +-------------------------------------------------------------------------------------------------------- +--------------------------------Scroll/Select Implementation-------------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--disables multiselect +local function disable_select_mode() + state.multiselect_start = nil + state.initial_selection = nil +end + +--enables multiselect +local function enable_select_mode() + state.multiselect_start = state.selected + state.initial_selection = API.copy_table(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 = state.selection[state.multiselect_start] + 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 > state.multiselect_start then + if new_pos > original_pos then + state.selection[i] = setting + elseif i ~= new_pos then + state.selection[i] = state.initial_selection[i] + end + elseif i < state.multiselect_start then + if new_pos < original_pos then + state.selection[i] = setting + elseif i ~= new_pos then + state.selection[i] = state.initial_selection[i] + end + end + end +end + +--moves the selector up and down the list by the entered amount +local function scroll(n, wrap) + local num_items = #state.list + if num_items == 0 then return end + + local original_pos = state.selected + + if original_pos + n > num_items then + state.selected = wrap and 1 or num_items + elseif original_pos + n < 1 then + state.selected = wrap and num_items or 1 + else + state.selected = original_pos + n + end + + if state.multiselect_start then drag_select(original_pos, state.selected) end + update_ass() +end + +--toggles the selection +local function toggle_selection() + if not state.list[state.selected] then return end + state.selection[state.selected] = not state.selection[state.selected] or nil + update_ass() +end + +--select all items in the list +local function select_all() + for i,_ in ipairs(state.list) do + state.selection[i] = true + end + update_ass() +end + +--toggles select mode +local function toggle_select_mode() + if state.multiselect_start == nil then + enable_select_mode() + toggle_selection() + else + disable_select_mode() + update_ass() + end +end + + + +-------------------------------------------------------------------------------------------------------- +-----------------------------------------Directory Movement--------------------------------------------- +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--selects the first item in the list which is highlighted as playing +local function select_playing_item() + for i,item in ipairs(state.list) do + if highlight_entry(item) then + 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 +local function select_prev_directory() + if state.prev_directory:find(state.directory, 1, true) == 1 then + local i = 1 + while (state.list[i] and API.parseable_item(state.list[i])) do + if state.prev_directory:find(API.get_full_path(state.list[i]), 1, true) then + state.selected = i + return + end + i = i+1 + end + end + + select_playing_item() +end + +--parses the given directory or defers to the next parser if nil is returned +local function choose_and_parse(directory, index) + msg.debug("finding parser for", directory) + local parser, list, opts + local parse_state = API.get_parse_state() + while list == nil and not parse_state.already_deferred and index <= #parsers do + parser = 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 +local function run_parse(directory, parse_state) + msg.verbose("scanning files in", directory) + parse_state.directory = directory + + local co = coroutine.running() + parse_states[co] = setmetatable(parse_state, { __index = parse_state_API }) + + if directory == "" then return root_parser:parse() end + local list, opts = choose_and_parse(directory, 1) + + if list == nil then return msg.debug("no successful parsers found") end + opts.parser = parsers[opts.id] + + if not opts.filtered then API.filter(list) end + if not opts.sorted then API.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 +local function parse_directory(directory, parse_state) + local co = API.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state)) + if not 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 + local new_co = coroutine.create(function() + API.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 + API.traceback(err, new_co) + API.coroutine.resume_err(co) + end + end) + return parse_states[co]:yield() +end + +--sends update requests to the different parsers +local function update_list(moving_adjacent) + msg.verbose('opening directory: ' .. state.directory) + + state.selected = 1 + state.selection = {} + + --loads the current directry from the cache to save loading time + --there will be a way to forcibly reload the current directory at some point + --the cache is in the form of a stack, items are taken off the stack when the dir moves up + if cache[1] and cache[#cache].directory == state.directory then + msg.verbose('found directory in cache') + cache:apply() + state.prev_directory = state.directory + return + end + local directory = state.directory + local list, opts = parse_directory(state.directory, { source = "browser" }) + + --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() ~= state.co then + msg.verbose(ABORT_ERROR.msg) + msg.debug("expected:", state.directory, "received:", directory) + return + end + + --apply fallbacks if the scan failed + if not list and cache[1] then + --switches settings back to the previously opened directory + --to the user it will be like the directory never changed + msg.warn("could not read directory", state.directory) + cache:apply() + return + elseif not list then + msg.warn("could not read directory", state.directory) + list, opts = root_parser:parse() + end + + state.list = list + state.parser = opts.parser + + --this only matters when displaying the list on the screen, so it doesn't need to be in the scan function + if not opts.escaped then + for i = 1, #list do + list[i].ass = list[i].ass or API.ass_escape(list[i].label or list[i].name, true) + end + end + + --setting custom options from parsers + state.directory_label = opts.directory_label + state.empty_text = opts.empty_text or state.empty_text + + --we assume that directory is only changed when redirecting to a different location + --therefore, the cache should be wiped + if opts.directory then + state.directory = opts.directory + cache:clear() + end + + if opts.selected_index then + state.selected = opts.selected_index or state.selected + if state.selected > #state.list then state.selected = #state.list + elseif state.selected < 1 then state.selected = 1 end + end + + if moving_adjacent then select_prev_directory() + else select_playing_item() end + state.prev_directory = state.directory +end + +--rescans the folder and updates the list +local function update(moving_adjacent) + --we can only make assumptions about the directory label when moving from adjacent directories + if not moving_adjacent then + state.directory_label = nil + cache:clear() + end + + state.empty_text = "~" + state.list = {} + disable_select_mode() + update_ass() + state.empty_text = "empty directory" + + --the directory is always handled within a coroutine to allow addons to + --pause execution for asynchronous operations + API.coroutine.run(function() + state.co = coroutine.running() + update_list(moving_adjacent) + update_ass() + end) +end + +--the base function for moving to a directory +local function goto_directory(directory) + state.directory = directory + update(false) +end + +--loads the root list +local function goto_root() + msg.verbose('jumping to root') + goto_directory("") +end + +--switches to the directory of the currently playing file +local function goto_current_dir() + msg.verbose('jumping to current directory') + goto_directory(current_file.directory) +end + +--moves up a directory +local function up_dir() + local dir = state.directory:reverse() + local index = dir:find("[/\\]") + + while index == 1 do + dir = dir:sub(2) + index = dir:find("[/\\]") + end + + if index == nil then state.directory = "" + else state.directory = dir:sub(index):reverse() end + + --we can make some assumptions about the next directory label when moving up or down + if state.directory_label then state.directory_label = state.directory_label:match("^(.+/)[^/]+/$") end + + update(true) + cache:pop() +end + +--moves down a directory +local function down_dir() + local current = state.list[state.selected] + if not current or not API.parseable_item(current) then return end + + cache:push() + local directory, redirected = API.get_new_directory(current, state.directory) + state.directory = directory + + --we can make some assumptions about the next directory label when moving up or down + if state.directory_label then state.directory_label = state.directory_label..(current.label or current.name) end + update(not redirected) +end + + + +------------------------------------------------------------------------------------------ +------------------------------------Browser Controls-------------------------------------- +------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------ + +--opens the browser +local function open() + if not state.hidden then return end + + for _,v in ipairs(state.keybinds) do + mp.add_forced_key_binding(v[1], 'dynamic/'..v[2], v[3], v[4]) + end + + utils.shared_script_property_set("file_browser-open", "yes") + state.hidden = false + if state.directory == nil then + local path = mp.get_property('path') + update_current_directory(nil, path) + if path or o.default_to_working_directory then goto_current_dir() else goto_root() end + return + end + + if state.flag_update then update_current_directory(nil, mp.get_property('path')) end + if not state.flag_update then ass:update() + else state.flag_update = false ; update_ass() end +end + +--closes the list and sets the hidden flag +local function close() + if state.hidden then return end + + for _,v in ipairs(state.keybinds) do + mp.remove_key_binding('dynamic/'..v[2]) + end + + utils.shared_script_property_set("file_browser-open", "no") + state.hidden = true + ass:remove() +end + +--toggles the list +local function toggle() + if state.hidden then open() + else close() end +end + +--run when the escape key is used +local function escape() + --if multiple items are selection cancel the + --selection instead of closing the browser + if next(state.selection) or state.multiselect_start then + state.selection = {} + disable_select_mode() + update_ass() + return + end + close() +end + +--opens a specific directory +local function browse_directory(directory) + if not directory then return end + directory = mp.command_native({"expand-path", directory}, "") + -- directory = join_path( mp.get_property("working-directory", ""), directory ) + + if directory ~= "" then directory = API.fix_path(directory, true) end + msg.verbose('recieved directory from script message: '..directory) + + if directory == "dvd://" then directory = dvd_device end + goto_directory(directory) + open() +end + + + +------------------------------------------------------------------------------------------ +---------------------------------File/Playlist Opening------------------------------------ +------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------ + +--adds a file to the playlist and changes the flag to `append-play` in preparation +--for future items +local function loadfile(file, opts) + if o.substitute_backslash and not API.get_protocol(file) then + file = file:gsub("/", "\\") + end + + if opts.flag == "replace" then msg.verbose("Playling file", file) + else msg.verbose("Appending", file, "to the playlist") end + + if not mp.commandv("loadfile", file, opts.flag) then msg.warn(file) end + opts.flag = "append-play" + opts.items_appended = opts.items_appended + 1 +end + +--this function recursively loads directories concurrently in separate coroutines +--results are saved in a tree of tables that allows asynchronous access +local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t) + --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 = parse_directory(directory, { source = "loadlist" }) + if list == 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") + item_t.type = "file" + return + end + + directory = list_opts.directory or directory + if directory == "" then return end + + --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) + item_t._sublist = list or {} + list._directory = directory + + --launches new parse operations for directories, each in a different coroutine + for _, item in ipairs(list) do + if API.parseable_item(item) then + API.coroutine.run(concurrent_loadlist_wrapper, API.get_new_directory(item, directory), load_opts, prev_dirs, item) + end + end + return true +end + +--a wrapper function that ensures the concurrent_loadlist_parse is run correctly +function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item) + --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 + API.coroutine.sleep(0.1) + end + opts.concurrency = opts.concurrency + 1 + + local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item) + opts.concurrency = opts.concurrency - 1 + if not success then item._sublist = {} end + if coroutine.status(opts.co) == "suspended" then API.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 +local function concurrent_loadlist_append(list, load_opts) + local directory = list._directory + + for _, item in ipairs(list) do + if not sub_extensions[ API.get_extension(item.name, "") ] + and not audio_extensions[ API.get_extension(item.name, "") ] + then + while (not item._sublist and API.parseable_item(item)) do + coroutine.yield() + end + + if API.parseable_item(item) then + concurrent_loadlist_append(item._sublist, load_opts) + else + loadfile(API.get_full_path(item, directory), load_opts) + end + end + end +end + +--recursive function to load directories using the script custom parsers +--returns true if any items were appended to the playlist +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 = parse_directory(directory, { source = "loadlist" }) + if list == 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.flag) + return true + end + + directory = opts.directory or directory + if directory == "" then return end + + for _, item in ipairs(list) do + if not sub_extensions[ API.get_extension(item.name, "") ] + and not audio_extensions[ API.get_extension(item.name, "") ] + then + if API.parseable_item(item) then + custom_loadlist_recursive( API.get_new_directory(item, directory) , load_opts, prev_dirs) + else + local path = API.get_full_path(item, directory) + loadfile(path, load_opts) + end + end + end +end + + +--a wrapper for the custom_loadlist_recursive function +local function loadlist(item, opts) + local dir = API.get_full_path(item, opts.directory) + local num_items = opts.items_appended + + if o.concurrent_recursion then + item = API.copy_table(item) + opts.co = API.coroutine.assert() + opts.concurrency = 0 + + --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 + mp.add_timeout(0, function() + API.coroutine.run(concurrent_loadlist_wrapper, dir, opts, {}, item) + end) + concurrent_loadlist_append({item, _directory = opts.directory}, opts) + 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 +local function autoload_dir(path, opts) + if o.autoload_save_current and path == 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 sub_extensions[ API.get_extension(item.name, "") ] + and not audio_extensions[ API.get_extension(item.name, "") ] + then + local p = API.get_full_path(item) + + if p == path then pos = file_count + else loadfile( p, opts) end + + file_count = file_count + 1 + end + end + mp.commandv("playlist-move", 0, pos+1) +end + +--runs the loadfile or loadlist command +local function open_item(item, opts) + if API.parseable_item(item) then + return loadlist(item, opts) + end + + local path = API.get_full_path(item, opts.directory) + if sub_extensions[ API.get_extension(item.name, "") ] then + mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto") + elseif audio_extensions[ API.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) 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 +local function open_file_coroutine(opts) + if not state.list[state.selected] then return end + if opts.flag == 'replace' then 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 = API.sort_keys(state.selection) + --reset the selection after + state.selection = {} + + disable_select_mode() + 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 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) + API.coroutine.run(open_file_coroutine, { + flag = flag, + autoload = (autoload ~= o.autoload and flag == "replace"), + directory = state.directory, + items_appended = 0 + }) +end + + + +------------------------------------------------------------------------------------------ +----------------------------------Keybind Implementation---------------------------------- +------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------ + +state.keybinds = { + {'ENTER', 'play', function() open_file('replace', false) end}, + {'Shift+ENTER', 'play_append', function() open_file('append-play', false) end}, + {'Alt+ENTER', 'play_autoload',function() open_file('replace', true) end}, + {'ESC', 'close', escape}, + {'RIGHT', 'down_dir', down_dir}, + {'LEFT', 'up_dir', up_dir}, + {'DOWN', 'scroll_down', function() scroll(1, o.wrap) end, {repeatable = true}}, + {'UP', 'scroll_up', function() scroll(-1, o.wrap) end, {repeatable = true}}, + {'PGDWN', 'page_down', function() scroll(o.num_entries) end, {repeatable = true}}, + {'PGUP', 'page_up', function() scroll(-o.num_entries) end, {repeatable = true}}, + {'Shift+PGDWN', 'list_bottom', function() scroll(math.huge) end}, + {'Shift+PGUP', 'list_top', function() scroll(-math.huge) end}, + {'HOME', 'goto_current', goto_current_dir}, + {'Shift+HOME', 'goto_root', goto_root}, + {'Ctrl+r', 'reload', function() cache:clear(); update() end}, + {'s', 'select_mode', toggle_select_mode}, + {'S', 'select_item', toggle_selection}, + {'Ctrl+a', 'select_all', select_all} +} + +--a map of key-keybinds - only saves the latest keybind if multiple have the same key code +local top_level_keys = {} + +--format the item string for either single or multiple items +local function create_item_string(cmd, items, funct) + if not items[1] then return end + + local str = funct(items[1]) + for i = 2, #items do + str = str .. ( cmd["concat-string"] or " " ) .. funct(items[i]) + end + return str +end + +--characters used for custom keybind codes +local CUSTOM_KEYBIND_CODES = "%%["..API.pattern_escape("%fFnNpPdDrR").."]" +local code_fns +code_fns = { + ["%f"] = function(cmd, items, s) + return create_item_string(cmd, items, function(item) + return item and API.get_full_path(item, s.directory) or "" + end) + end, + ["%F"] = function(cmd, items, s) + return create_item_string(cmd, items, function(item) + return ("%q"):format(item and API.get_full_path(item, s.directory) or "") + end) + end, + ["%n"] = function(cmd, items) + return create_item_string(cmd, items, function(item) + return item and (item.label or item.name) or "" + end) + end, + ["%N"] = function(cmd, items) + return create_item_string(cmd, items, function(item) + return ("%q"):format(item and (item.label or item.name) or "") + end) + end, + + ["%%"] = "%", + ["%p"] = function(_, _, s) return 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, +} + +--iterates through the command table and substitutes special +--character codes for the correct strings used for custom functions +local function format_command_table(cmd, items, state) + local copy = {} + for i = 1, #cmd.command do + copy[i] = {} + + for j = 1, #cmd.command[i] do + copy[i][j] = cmd.command[i][j]:gsub(CUSTOM_KEYBIND_CODES, function(code) + if type(code_fns[code]) == "string" then return code_fns[code] end + + --encapsulates the string if using an uppercase code + if not code_fns[code] then + local lower = code_fns[code:lower()] + if not lower then return end + return string.format("%q", lower(cmd, items, state)) + end + + return code_fns[code](cmd, items, state) + end) + end + end + return copy +end + +--runs all of the commands in the command table +--key.command must be an array of command tables compatible with mp.command_native +--items must be an array of multiple items (when multi-type ~= concat the array will be 1 long) +local function run_custom_command(cmd, items, state) + local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command + + for _, cmd in ipairs(custom_cmds) do + msg.debug("running command:", utils.to_string(cmd)) + mp.command_native(cmd) + end +end + +--runs one of the custom commands +local function custom_command(cmd, state, co) + if cmd.parser and cmd.parser ~= (state.parser.keybind_name or state.parser.name) then return false 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 the directory is empty, and this command needs to work on an item, then abort and fallback to the next command + if cmd.codes and not state.list[state.selected] then + if cmd.codes["%f"] or cmd.codes["%F"] or cmd.codes["%n"] or cmd.codes["%N"] then return false end + end + + run_custom_command(cmd, { state.list[state.selected] }, state) + return true + end + + --runs the command on all multi-selected items + local selection = API.sort_keys(state.selection, function(item) 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" then + for i,_ in ipairs(selection) do + run_custom_command(cmd, {selection[i]}, state) + + if cmd.delay then + mp.add_timeout(cmd.delay, function() API.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 +local function run_keybind_recursive(keybind, state, co) + msg.trace("Attempting custom command:", utils.to_string(keybind)) + + --these are for the default keybinds, or from addons which use direct functions + local addon_fn = type(keybind.command) == "function" + local fn = addon_fn and keybind.command or custom_command + + if keybind.passthrough ~= nil then + fn(keybind, addon_fn and API.copy_table(state) or state, co) + if keybind.passthrough == true and keybind.prev_key then + run_keybind_recursive(keybind.prev_key, state, co) + end + else + if fn(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 +local function run_keybind_coroutine(key) + msg.debug("Received custom keybind "..key.key) + local co = coroutine.create(run_keybind_recursive) + + local state_copy = { + directory = state.directory, + directory_label = state.directory_label, + list = state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables + selected = state.selected, + selection = API.copy_table(state.selection), + parser = 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)) + API.traceback(err, co) + end +end + +--scans the given command table to identify if they contain any custom keybind codes +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 + value:gsub(CUSTOM_KEYBIND_CODES, function(code) 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 +local function insert_custom_keybind(keybind) + --we'll always save the keybinds as either an array of command arrays or a function + if type(keybind.command) == "table" and type(keybind.command[1]) ~= "table" then + keybind.command = {keybind.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] + + table.insert(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() + if not o.custom_keybinds and not o.addons then return end + + --this is to make the default keybinds compatible with passthrough from custom keybinds + for _, keybind in ipairs(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 + if o.addons then + for i = #parsers, 1, -1 do + local parser = 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 = API.copy_table(keybind) end + + keybind.name = parsers[parser].id.."/"..(keybind.name or tostring(i)) + insert_custom_keybind(keybind) + end + end + end + end + + --loads custom keybinds from file-browser-keybinds.json + if o.custom_keybinds then + local path = mp.command_native({"expand-path", "~~/script-opts"}).."/file-browser-keybinds.json" + local custom_keybinds, err = io.open( path ) + if not custom_keybinds then return error(err) 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) do + keybind.name = "custom/"..(keybind.name or tostring(i)) + insert_custom_keybind(keybind) + end + end +end + + + +-------------------------------------------------------------------------------------------------------- +-------------------------------------------API Functions------------------------------------------------ +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +--these functions we'll provide as-is +API.redraw = update_ass +API.rescan = update +API.browse_directory = browse_directory + +function API.clear_cache() + cache:clear() +end + +--a wrapper around scan_directory for addon API +function API.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 parse_directory(directory, parse_state) +end + +--register file extensions which can be opened by the browser +function API.register_parseable_extension(ext) + parseable_extensions[string.lower(ext)] = true +end +function API.remove_parseable_extension(ext) + parseable_extensions[string.lower(ext)] = nil +end + +--add a compatible extension to show through the filter, only applies if run during the setup() method +function API.add_default_extension(ext) + table.insert(compatible_file_extensions, ext) +end + +--add item to root at position pos +function API.insert_root_item(item, pos) + msg.verbose("adding item to root", item.label or item.name) + item.ass = item.ass or API.ass_escape(item.label or item.name) + item.type = "dir" + table.insert(root, pos or (#root + 1), item) +end + +--providing getter and setter functions so that addons can't modify things directly +function API.get_script_opts() return API.copy_table(o) end +function API.get_opt(key) return o[key] end +function API.get_extensions() return API.copy_table(extensions) end +function API.get_sub_extensions() return API.copy_table(sub_extensions) end +function API.get_audio_extensions() return API.copy_table(audio_extensions) end +function API.get_parseable_extensions() return API.copy_table(parseable_extensions) end +function API.get_state() return API.copy_table(state) end +function API.get_dvd_device() return dvd_device end +function API.get_parsers() return API.copy_table(parsers) end +function API.get_root() return API.copy_table(root) end +function API.get_directory() return state.directory end +function API.get_list() return API.copy_table(state.list) end +function API.get_current_file() return API.copy_table(current_file) end +function API.get_current_parser() return state.parser:get_id() end +function API.get_current_parser_keyname() return state.parser.keybind_name or state.parser.name end +function API.get_selected_index() return state.selected end +function API.get_selected_item() return API.copy_table(state.list[state.selected]) end +function API.get_open_status() return not state.hidden end +function API.get_parse_state(co) return parse_states[co or coroutine.running() or ""] end + +function API.set_empty_text(str) + state.empty_text = str + API.redraw() +end + +function API.set_selected_index(index) + if type(index) ~= "number" then return false end + if index < 1 then index = 1 end + if index > #state.list then index = #state.list end + state.selected = index + API.redraw() + return index +end + +function parser_API:get_index() return parsers[self].index end +function parser_API:get_id() return parsers[self].id end + +--runs choose_and_parse starting from the next parser +function parser_API:defer(directory) + msg.trace("deferring to other parsers...") + local list, opts = choose_and_parse(directory, self:get_index() + 1) + API.get_parse_state().already_deferred = true + return list, opts +end + +--a wrapper around coroutine.yield that aborts the coroutine if +--the parse request was cancelled by the user +--the coroutine is +function parse_state_API:yield(...) + local co = coroutine.running() + local is_browser = co == state.co + if self.source == "browser" and not is_browser then + msg.error("current coroutine does not match browser's expected coroutine - did you unsafely yield before this?") + error("current coroutine does not match browser's expected coroutine - aborting the parse") + end + + local result = table.pack(coroutine.yield(...)) + if is_browser and co ~= state.co then + msg.verbose("browser no longer waiting for list - aborting parse for", self.directory) + error(ABORT_ERROR) + end + return unpack(result, 1, result.n) +end + +--checks if the current coroutine is the one handling the browser's request +function parse_state_API:is_coroutine_current() + return coroutine.running() == state.co +end + + + +-------------------------------------------------------------------------------------------------------- +-----------------------------------------Setup Functions------------------------------------------------ +-------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------- + +local API_MAJOR, API_MINOR, API_PATCH = API_VERSION:match("(%d+)%.(%d+)%.(%d+)") + +--checks if the given parser has a valid version number +local function check_api_version(parser) + local version = parser.version or "1.0.0" + + local major, minor = version:match("(%d+)%.(%d+)") + + if not major or not minor then + return msg.error("Invalid version number") + elseif major ~= API_MAJOR then + return msg.error("parser", parser.name, "has wrong major version number, expected", ("v%d.x.x"):format(API_MAJOR), "got", 'v'..version) + elseif minor > API_MINOR then + msg.warn("parser", parser.name, "has newer minor version number than API, expected", ("v%d.%d.x"):format(API_MAJOR, API_MINOR), "got", 'v'..version) + end + return true +end + +--create a unique id for the given parser +local function set_parser_id(parser) + local name = parser.name + if parsers[name] then + local n = 2 + name = parser.name.."_"..n + while parsers[name] do + n = n + 1 + name = parser.name.."_"..n + end + end + + parsers[name] = parser + parsers[parser] = { id = name } +end + +local function redirect_table(t) + return setmetatable({}, { __index = t }) +end + +--loads an addon in a separate environment +local function load_addon(path) + local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$")) + local addon_environment = redirect_table(_G) + addon_environment._G = addon_environment + + --gives each addon custom debug messages + addon_environment.package = redirect_table(addon_environment.package) + addon_environment.package.loaded = 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 + + addon_environment.require = function(module) + if module == "mp.msg" then return msg_module end + return require(module) + end + + local chunk, err + if setfenv then + --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) + else + chunk, err = _G.loadfile(path, "bt", addon_environment) + if not chunk then return msg.error(err) end + end + + local success, result = xpcall(chunk, API.traceback) + return success and result or nil +end + +--setup an internal or external parser +local function setup_parser(parser, file) + parser = setmetatable(parser, { __index = parser_API }) + parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "") + + set_parser_id(parser) + if not check_api_version(parser) 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(parsers, parser) +end + +--load an external addon +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 = load_addon(path) + 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) do + setup_parser(parser, file) + end +end + +--loading external addons +local function setup_addons() + local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'}) + local files = utils.readdir(addon_dir) + if not files then error("could not read addon directory") end + + for _, file in ipairs(files) do + setup_addon(file, addon_dir..file) + end + table.sort(parsers, function(a, b) return a.priority < b.priority end) + + --we want to store the indexes of the parsers + for i = #parsers, 1, -1 do parsers[ parsers[i] ].index = i end + + --we want to run the setup functions for each addon + for index, parser in ipairs(parsers) do + if parser.setup then + local success = xpcall(function() parser:setup() end, API.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(parsers, index) + end + end + end +end + +--sets up the compatible extensions list +local function setup_extensions_list() + --setting up subtitle extensions + for ext in API.iterate_opt(o.subtitle_extensions:lower()) do + sub_extensions[ext] = true + extensions[ext] = true + end + + --setting up audio extensions + for ext in API.iterate_opt(o.audio_extensions:lower()) do + audio_extensions[ext] = true + extensions[ext] = true + end + + --adding file extensions to the set + for _, ext in ipairs(compatible_file_extensions) do + extensions[ext] = true + end + + --adding extra extensions on the whitelist + for str in API.iterate_opt(o.extension_whitelist:lower()) do + extensions[str] = true + end + + --removing extensions that are in the blacklist + for str in API.iterate_opt(o.extension_blacklist:lower()) do + extensions[str] = nil + end +end + +--splits the string into a table on the semicolons +local function setup_root() + root = {} + for str in API.iterate_opt(o.root) do + local path = mp.command_native({'expand-path', str}) + path = API.fix_path(path, true) + + local temp = {name = path, type = 'dir', label = str, ass = API.ass_escape(str, true)} + + root[#root+1] = temp + end +end + +setup_root() + +setup_parser(file_parser, "file-browser.lua") +if o.addons then + --all of the API functions need to be defined before this point for the addons to be able to access them safely + setup_addons() +end + +--these need to be below the addon setup in case any parsers add custom entries +setup_extensions_list() +setup_keybinds() + + + +------------------------------------------------------------------------------------------ +------------------------------Other Script Compatability---------------------------------- +------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------ + +local function scan_directory_json(directory, response_str) + 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}, "") + if directory ~= "" then directory = API.fix_path(directory, true) end + msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str)) + + local list, opts = parse_directory(directory, { source = "script-message" } ) + opts.API_VERSION = API_VERSION + + local err + list, err = API.format_json_safe(list) + if not list then msg.error(err) end + + opts, err = API.format_json_safe(opts) + if not opts then msg.error(err) end + + mp.commandv("script-message", response_str, list or "", opts or "") +end + +pcall(function() + local input = require "user-input-module" + mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function() + input.get_user_input(browse_directory, {request_text = "open directory:"}) + end) +end) + + + +------------------------------------------------------------------------------------------ +--------------------------------mpv API Callbacks----------------------------------------- +------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------ + +--we don't want to add any overhead when the browser isn't open +mp.observe_property('path', 'string', function(_,path) + if not state.hidden then + update_current_directory(_,path) + update_ass() + else state.flag_update = true end +end) + +--updates the dvd_device +mp.observe_property('dvd-device', 'string', function(_, device) + if not device or device == "" then device = "/dev/dvd/" end + dvd_device = API.fix_path(device, true) +end) + +--declares the keybind to open the browser +mp.add_key_binding('MENU','browse-files', toggle) +mp.add_key_binding('Ctrl+o','open-browser', open) + +--allows keybinds/other scripts to auto-open specific directories +mp.register_script_message('browse-directory', browse_directory) + +--allows other scripts to request directory contents from file-browser +mp.register_script_message("get-directory-contents", function(directory, response_str) + API.coroutine.run(scan_directory_json, directory, response_str) +end) + diff --git a/mpv/.config/mpv/scripts/modern.lua b/mpv/.config/mpv/scripts/modern.lua new file mode 100644 index 0000000..41db0f8 --- /dev/null +++ b/mpv/.config/mpv/scripts/modern.lua @@ -0,0 +1,2125 @@ +-- by maoiscat +-- email:valarmor@163.com +-- https://github.com/maoiscat/mpv-osc-morden + +local assdraw = require 'mp.assdraw' +local msg = require 'mp.msg' +local opt = require 'mp.options' +local utils = require 'mp.utils' + +-- +-- Parameters +-- +-- default user option values +-- may change them in osc.conf +local user_opts = { + showwindowed = true, -- show OSC when windowed? + showfullscreen = true, -- show OSC when fullscreen? + scalewindowed = 1, -- scaling of the controller when windowed + scalefullscreen = 1, -- scaling of the controller when fullscreen + scaleforcedwindow = 2, -- scaling when rendered on a forced window + vidscale = false, -- scale the controller with the video? + hidetimeout = 1000, -- duration in ms until the OSC hides if no + -- mouse movement. enforced non-negative for the + -- user, but internally negative is 'always-on'. + fadeduration = 500, -- duration of fade out in ms, 0 = no fade + minmousemove = 3, -- minimum amount of pixels the mouse has to + -- move between ticks to make the OSC show up + iamaprogrammer = false, -- use native mpv values and disable OSC + -- internal track list management (and some + -- functions that depend on it) + font = 'mpv-osd-symbols', -- default osc font + seekbarhandlesize = 1.0, -- size ratio of the slider handle, range 0 ~ 1 + seekrange = true, -- show seekrange overlay + seekrangealpha = 128, -- transparency of seekranges + seekbarkeyframes = true, -- use keyframes when dragging the seekbar + title = '${media-title}', -- string compatible with property-expansion + -- to be shown as OSC title + showtitle = true, -- show title and no hide timeout on pause + timetotal = true, -- display total time instead of remaining time? + visibility = 'auto', -- only used at init to set visibility_mode(...) + windowcontrols = 'auto', -- whether to show window controls + volumecontrol = true, -- whether to show mute button and volumne slider + language = 'eng', -- eng=English, chs=Chinese +} + +-- Localization +local language = { + ['eng'] = { + welcome = '{\\fs24\\1c&H0&\\3c&HFFFFFF&}Drop files or URLs to play here.', -- this text appears when mpv starts + off = 'OFF', + na = 'n/a', + none = 'none', + video = 'Video', + audio = 'Audio', + subtitle = 'Subtitle', + available = 'Available ', + track = ' Tracks:', + playlist = 'Playlist', + nolist = 'Empty playlist.', + chapter = 'Chapter', + nochapter = 'No chapters.', + }, + ['chs'] = { + welcome = '{\\1c&H00\\bord0\\fs30\\fn微软雅黑 light\\fscx125}MPV{\\fscx100} 播放器', -- this text appears when mpv starts + off = '关闭', + na = 'n/a', + none = '无', + video = '视频', + audio = '音频', + subtitle = '字幕', + available = '可选', + track = ':', + playlist = '播放列表', + nolist = '无列表信息', + chapter = '章节', + nochapter = '无章节信息', + } +} +-- read options from config and command-line +opt.read_options(user_opts, 'osc', function(list) update_options(list) end) +-- apply lang opts +local texts = language[user_opts.language] +local osc_param = { -- calculated by osc_init() + playresy = 0, -- canvas size Y + playresx = 0, -- canvas size X + display_aspect = 1, + unscaled_y = 0, + areas = {}, +} + +local osc_styles = { + TransBg = '{\\blur100\\bord140\\1c&H000000&\\3c&H000000&}', + SeekbarBg = '{\\blur0\\bord0\\1c&HFFFFFF&}', + SeekbarFg = '{\\blur1\\bord1\\1c&HE39C42&}', + VolumebarBg = '{\\blur0\\bord0\\1c&H999999&}', + VolumebarFg = '{\\blur1\\bord1\\1c&HFFFFFF&}', + Ctrl1 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs36\\fnmaterial-design-iconic-font}', + Ctrl2 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs24\\fnmaterial-design-iconic-font}', + Ctrl3 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs24\\fnmaterial-design-iconic-font}', + Time = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&H000000&\\fs17\\fn' .. user_opts.font .. '}', + Tooltip = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H000000&\\fs18\\fn' .. user_opts.font .. '}', + Title = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H0\\fs48\\q2\\fn' .. user_opts.font .. '}', + WinCtrl = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H0\\fs20\\fnmpv-osd-symbols}', + elementDown = '{\\1c&H999999&}', +} + +-- internal states, do not touch +local state = { + showtime, -- time of last invocation (last mouse move) + osc_visible = false, + anistart, -- time when the animation started + anitype, -- current type of animation + animation, -- current animation alpha + mouse_down_counter = 0, -- used for softrepeat + active_element = nil, -- nil = none, 0 = background, 1+ = see elements[] + active_event_source = nil, -- the 'button' that issued the current event + rightTC_trem = not user_opts.timetotal, -- if the right timecode should display total or remaining time + mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs + initREQ = false, -- is a re-init request pending? + last_mouseX, last_mouseY, -- last mouse position, to detect significant mouse movement + mouse_in_window = false, + message_text, + message_hide_timer, + fullscreen = false, + tick_timer = nil, + tick_last_time = 0, -- when the last tick() was run + hide_timer = nil, + cache_state = nil, + idle = false, + enabled = true, + input_enabled = true, + showhide_enabled = false, + dmx_cache = 0, + border = true, + maximized = false, + osd = mp.create_osd_overlay('ass-events'), + mute = false, + lastvisibility = user_opts.visibility, -- save last visibility on pause if showtitle +} + +local window_control_box_width = 138 +local tick_delay = 0.03 + +-- +-- Helperfunctions +-- + +function set_osd(res_x, res_y, text) + if state.osd.res_x == res_x and + state.osd.res_y == res_y and + state.osd.data == text then + return + end + state.osd.res_x = res_x + state.osd.res_y = res_y + state.osd.data = text + state.osd.z = 1000 + state.osd:update() +end + +-- scale factor for translating between real and virtual ASS coordinates +function get_virt_scale_factor() + local w, h = mp.get_osd_size() + if w <= 0 or h <= 0 then + return 0, 0 + end + return osc_param.playresx / w, osc_param.playresy / h +end + +-- return mouse position in virtual ASS coordinates (playresx/y) +function get_virt_mouse_pos() + if state.mouse_in_window then + local sx, sy = get_virt_scale_factor() + local x, y = mp.get_mouse_pos() + return x * sx, y * sy + else + return -1, -1 + end +end + +function set_virt_mouse_area(x0, y0, x1, y1, name) + local sx, sy = get_virt_scale_factor() + mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name) +end + +function scale_value(x0, x1, y0, y1, val) + local m = (y1 - y0) / (x1 - x0) + local b = y0 - (m * x0) + return (m * val) + b +end + +-- returns hitbox spanning coordinates (top left, bottom right corner) +-- according to alignment +function get_hitbox_coords(x, y, an, w, h) + + local alignments = { + [1] = function () return x, y-h, x+w, y end, + [2] = function () return x-(w/2), y-h, x+(w/2), y end, + [3] = function () return x-w, y-h, x, y end, + + [4] = function () return x, y-(h/2), x+w, y+(h/2) end, + [5] = function () return x-(w/2), y-(h/2), x+(w/2), y+(h/2) end, + [6] = function () return x-w, y-(h/2), x, y+(h/2) end, + + [7] = function () return x, y, x+w, y+h end, + [8] = function () return x-(w/2), y, x+(w/2), y+h end, + [9] = function () return x-w, y, x, y+h end, + } + + return alignments[an]() +end + +function get_hitbox_coords_geo(geometry) + return get_hitbox_coords(geometry.x, geometry.y, geometry.an, + geometry.w, geometry.h) +end + +function get_element_hitbox(element) + return element.hitbox.x1, element.hitbox.y1, + element.hitbox.x2, element.hitbox.y2 +end + +function mouse_hit(element) + return mouse_hit_coords(get_element_hitbox(element)) +end + +function mouse_hit_coords(bX1, bY1, bX2, bY2) + local mX, mY = get_virt_mouse_pos() + return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2) +end + +function limit_range(min, max, val) + if val > max then + val = max + elseif val < min then + val = min + end + return val +end + +-- translate value into element coordinates +function get_slider_ele_pos_for(element, val) + + local ele_pos = scale_value( + element.slider.min.value, element.slider.max.value, + element.slider.min.ele_pos, element.slider.max.ele_pos, + val) + + return limit_range( + element.slider.min.ele_pos, element.slider.max.ele_pos, + ele_pos) +end + +-- translates global (mouse) coordinates to value +function get_slider_value_at(element, glob_pos) + + local val = scale_value( + element.slider.min.glob_pos, element.slider.max.glob_pos, + element.slider.min.value, element.slider.max.value, + glob_pos) + + return limit_range( + element.slider.min.value, element.slider.max.value, + val) +end + +-- get value at current mouse position +function get_slider_value(element) + return get_slider_value_at(element, get_virt_mouse_pos()) +end + +function countone(val) + if not (user_opts.iamaprogrammer) then + val = val + 1 + end + return val +end + +-- multiplies two alpha values, formular can probably be improved +function mult_alpha(alphaA, alphaB) + return 255 - (((1-(alphaA/255)) * (1-(alphaB/255))) * 255) +end + +function add_area(name, x1, y1, x2, y2) + -- create area if needed + if (osc_param.areas[name] == nil) then + osc_param.areas[name] = {} + end + table.insert(osc_param.areas[name], {x1=x1, y1=y1, x2=x2, y2=y2}) +end + +function ass_append_alpha(ass, alpha, modifier) + local ar = {} + + for ai, av in pairs(alpha) do + av = mult_alpha(av, modifier) + if state.animation then + av = mult_alpha(av, state.animation) + end + ar[ai] = av + end + + ass:append(string.format('{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}', + ar[1], ar[2], ar[3], ar[4])) +end + +function ass_draw_cir_cw(ass, x, y, r) + ass:round_rect_cw(x-r, y-r, x+r, y+r, r) +end + +function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_cw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_cw(x0, y0, x1, y1, r1, r2) + end +end + +function ass_draw_rr_h_ccw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_ccw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_ccw(x0, y0, x1, y1, r1, r2) + end +end + + +-- +-- Tracklist Management +-- + +local nicetypes = {video = texts.video, audio = texts.audio, sub = texts.subtitle} + +-- updates the OSC internal playlists, should be run each time the track-layout changes +function update_tracklist() + local tracktable = mp.get_property_native('track-list', {}) + + -- by osc_id + tracks_osc = {} + tracks_osc.video, tracks_osc.audio, tracks_osc.sub = {}, {}, {} + -- by mpv_id + tracks_mpv = {} + tracks_mpv.video, tracks_mpv.audio, tracks_mpv.sub = {}, {}, {} + for n = 1, #tracktable do + if not (tracktable[n].type == 'unknown') then + local type = tracktable[n].type + local mpv_id = tonumber(tracktable[n].id) + + -- by osc_id + table.insert(tracks_osc[type], tracktable[n]) + + -- by mpv_id + tracks_mpv[type][mpv_id] = tracktable[n] + tracks_mpv[type][mpv_id].osc_id = #tracks_osc[type] + end + end +end + +-- return a nice list of tracks of the given type (video, audio, sub) +function get_tracklist(type) + local msg = texts.available .. nicetypes[type] .. texts.track + if #tracks_osc[type] == 0 then + msg = msg .. texts.none + else + for n = 1, #tracks_osc[type] do + local track = tracks_osc[type][n] + local lang, title, selected = 'unknown', '', '○' + if not(track.lang == nil) then lang = track.lang end + if not(track.title == nil) then title = track.title end + if (track.id == tonumber(mp.get_property(type))) then + selected = '●' + end + msg = msg..'\n'..selected..' '..n..': ['..lang..'] '..title + end + end + return msg +end + +-- relatively change the track of given by tracks + --(+1 -> next, -1 -> previous) +function set_track(type, next) + local current_track_mpv, current_track_osc + if (mp.get_property(type) == 'no') then + current_track_osc = 0 + else + current_track_mpv = tonumber(mp.get_property(type)) + current_track_osc = tracks_mpv[type][current_track_mpv].osc_id + end + local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1) + local new_track_mpv + if new_track_osc == 0 then + new_track_mpv = 'no' + else + new_track_mpv = tracks_osc[type][new_track_osc].id + end + + mp.commandv('set', type, new_track_mpv) + +-- if (new_track_osc == 0) then +-- show_message(nicetypes[type] .. ' Track: none') +-- else +-- show_message(nicetypes[type] .. ' Track: ' +-- .. new_track_osc .. '/' .. #tracks_osc[type] +-- .. ' ['.. (tracks_osc[type][new_track_osc].lang or 'unknown') ..'] ' +-- .. (tracks_osc[type][new_track_osc].title or '')) +-- end +end + +-- get the currently selected track of , OSC-style counted +function get_track(type) + local track = mp.get_property(type) + if track ~= 'no' and track ~= nil then + local tr = tracks_mpv[type][tonumber(track)] + if tr then + return tr.osc_id + end + end + return 0 +end + +-- WindowControl helpers +function window_controls_enabled() + val = user_opts.windowcontrols + if val == 'auto' then + return (not state.border) or state.fullscreen + else + return val ~= 'no' + end +end + +-- +-- Element Management +-- + +local elements = {} + +function prepare_elements() + + -- remove elements without layout or invisble + local elements2 = {} + for n, element in pairs(elements) do + if not (element.layout == nil) and (element.visible) then + table.insert(elements2, element) + end + end + elements = elements2 + + function elem_compare (a, b) + return a.layout.layer < b.layout.layer + end + + table.sort(elements, elem_compare) + + + for _,element in pairs(elements) do + + local elem_geo = element.layout.geometry + + -- Calculate the hitbox + local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo) + element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2} + + local style_ass = assdraw.ass_new() + + -- prepare static elements + style_ass:append('{}') -- hack to troll new_event into inserting a \n + style_ass:new_event() + style_ass:pos(elem_geo.x, elem_geo.y) + style_ass:an(elem_geo.an) + style_ass:append(element.layout.style) + + element.style_ass = style_ass + + local static_ass = assdraw.ass_new() + + + if (element.type == 'box') then + --draw box + static_ass:draw_start() + ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, + element.layout.box.radius, element.layout.box.hexagon) + static_ass:draw_stop() + + elseif (element.type == 'slider') then + --draw static slider parts + local slider_lo = element.layout.slider + -- calculate positions of min and max points + element.slider.min.ele_pos = user_opts.seekbarhandlesize * elem_geo.h / 2 + element.slider.max.ele_pos = elem_geo.w - element.slider.min.ele_pos + element.slider.min.glob_pos = element.hitbox.x1 + element.slider.min.ele_pos + element.slider.max.glob_pos = element.hitbox.x1 + element.slider.max.ele_pos + + static_ass:draw_start() + -- a hack which prepares the whole slider area to allow center placements such like an=5 + static_ass:rect_cw(0, 0, elem_geo.w, elem_geo.h) + static_ass:rect_ccw(0, 0, elem_geo.w, elem_geo.h) + -- marker nibbles + if not (element.slider.markerF == nil) and (slider_lo.gap > 0) then + local markers = element.slider.markerF() + for _,marker in pairs(markers) do + if (marker >= element.slider.min.value) and (marker <= element.slider.max.value) then + local s = get_slider_ele_pos_for(element, marker) + if (slider_lo.gap > 5) then -- draw triangles + --top + if (slider_lo.nibbles_top) then + static_ass:move_to(s - 3, slider_lo.gap - 5) + static_ass:line_to(s + 3, slider_lo.gap - 5) + static_ass:line_to(s, slider_lo.gap - 1) + end + --bottom + if (slider_lo.nibbles_bottom) then + static_ass:move_to(s - 3, elem_geo.h - slider_lo.gap + 5) + static_ass:line_to(s, elem_geo.h - slider_lo.gap + 1) + static_ass:line_to(s + 3, elem_geo.h - slider_lo.gap + 5) + end + else -- draw 2x1px nibbles + --top + if (slider_lo.nibbles_top) then + static_ass:rect_cw(s - 1, 0, s + 1, slider_lo.gap); + end + --bottom + if (slider_lo.nibbles_bottom) then + static_ass:rect_cw(s - 1, elem_geo.h-slider_lo.gap, s + 1, elem_geo.h); + end + end + end + end + end + end + + element.static_ass = static_ass + + -- if the element is supposed to be disabled, + -- style it accordingly and kill the eventresponders + if not (element.enabled) then + element.layout.alpha[1] = 136 + element.eventresponder = nil + end + end +end + +-- +-- Element Rendering +-- +function render_elements(master_ass) + + for n=1, #elements do + local element = elements[n] + local style_ass = assdraw.ass_new() + style_ass:merge(element.style_ass) + ass_append_alpha(style_ass, element.layout.alpha, 0) + + if element.eventresponder and (state.active_element == n) then + -- run render event functions + if not (element.eventresponder.render == nil) then + element.eventresponder.render(element) + end + if mouse_hit(element) then + -- mouse down styling + if (element.styledown) then + style_ass:append(osc_styles.elementDown) + end + if (element.softrepeat) and (state.mouse_down_counter >= 15 + and state.mouse_down_counter % 5 == 0) then + + element.eventresponder[state.active_event_source..'_down'](element) + end + state.mouse_down_counter = state.mouse_down_counter + 1 + end + end + + local elem_ass = assdraw.ass_new() + elem_ass:merge(style_ass) + + if not (element.type == 'button') then + elem_ass:merge(element.static_ass) + end + + if (element.type == 'slider') then + + local slider_lo = element.layout.slider + local elem_geo = element.layout.geometry + local s_min = element.slider.min.value + local s_max = element.slider.max.value + -- draw pos marker + local pos = element.slider.posF() + local seekRanges = element.slider.seekRangesF() + local rh = user_opts.seekbarhandlesize * elem_geo.h / 2 -- Handle radius + local xp + + if pos then + xp = get_slider_ele_pos_for(element, pos) + ass_draw_cir_cw(elem_ass, xp, elem_geo.h/2, rh) + elem_ass:rect_cw(0, slider_lo.gap, xp, elem_geo.h - slider_lo.gap) + end + + if seekRanges then + elem_ass:draw_stop() + elem_ass:merge(element.style_ass) + ass_append_alpha(elem_ass, element.layout.alpha, user_opts.seekrangealpha) + elem_ass:merge(element.static_ass) + + for _,range in pairs(seekRanges) do + local pstart = get_slider_ele_pos_for(element, range['start']) + local pend = get_slider_ele_pos_for(element, range['end']) + elem_ass:rect_cw(pstart - rh, slider_lo.gap, pend + rh, elem_geo.h - slider_lo.gap) + end + end + + elem_ass:draw_stop() + + -- add tooltip + if not (element.slider.tooltipF == nil) then + if mouse_hit(element) then + local sliderpos = get_slider_value(element) + local tooltiplabel = element.slider.tooltipF(sliderpos) + local an = slider_lo.tooltip_an + local ty + if (an == 2) then + ty = element.hitbox.y1 + else + ty = element.hitbox.y1 + elem_geo.h/2 + end + + local tx = get_virt_mouse_pos() + if (slider_lo.adjust_tooltip) then + if (an == 2) then + if (sliderpos < (s_min + 3)) then + an = an - 1 + elseif (sliderpos > (s_max - 3)) then + an = an + 1 + end + elseif (sliderpos > (s_max-s_min)/2) then + an = an + 1 + tx = tx - 5 + else + an = an - 1 + tx = tx + 10 + end + end + + -- tooltip label + elem_ass:new_event() + elem_ass:pos(tx, ty) + elem_ass:an(an) + elem_ass:append(slider_lo.tooltip_style) + ass_append_alpha(elem_ass, slider_lo.alpha, 0) + elem_ass:append(tooltiplabel) + end + end + + elseif (element.type == 'button') then + + local buttontext + if type(element.content) == 'function' then + buttontext = element.content() -- function objects + elseif not (element.content == nil) then + buttontext = element.content -- text objects + end + + buttontext = buttontext:gsub(':%((.?.?.?)%) unknown ', ':%(%1%)') --gsub('%) unknown %(\'', '') + + local maxchars = element.layout.button.maxchars + -- 认为1个中文字符约等于1.5个英文字符 + local charcount = (buttontext:len() + select(2, buttontext:gsub('[^\128-\193]', ''))*2) / 3 + if not (maxchars == nil) and (charcount > maxchars) then + local limit = math.max(0, maxchars - 3) + if (charcount > limit) then + while (charcount > limit) do + buttontext = buttontext:gsub('.[\128-\191]*$', '') + charcount = (buttontext:len() + select(2, buttontext:gsub('[^\128-\193]', ''))*2) / 3 + end + buttontext = buttontext .. '...' + end + end + + elem_ass:append(buttontext) + + -- add tooltip + if not (element.tooltipF == nil) and element.enabled then + if mouse_hit(element) then + local tooltiplabel = element.tooltipF + local an = 1 + local ty = element.hitbox.y1 + local tx = get_virt_mouse_pos() + + if ty < osc_param.playresy / 2 then + ty = element.hitbox.y2 + an = 7 + end + + -- tooltip label + if type(element.tooltipF) == 'function' then + tooltiplabel = element.tooltipF() + else + tooltiplabel = element.tooltipF + end + elem_ass:new_event() + elem_ass:pos(tx, ty) + elem_ass:an(an) + elem_ass:append(element.tooltip_style) + elem_ass:append(tooltiplabel) + end + end + end + + master_ass:merge(elem_ass) + end +end + +-- +-- Message display +-- + +-- pos is 1 based +function limited_list(prop, pos) + local proplist = mp.get_property_native(prop, {}) + local count = #proplist + if count == 0 then + return count, proplist + end + + local fs = tonumber(mp.get_property('options/osd-font-size')) + local max = math.ceil(osc_param.unscaled_y*0.75 / fs) + if max % 2 == 0 then + max = max - 1 + end + local delta = math.ceil(max / 2) - 1 + local begi = math.max(math.min(pos - delta, count - max + 1), 1) + local endi = math.min(begi + max - 1, count) + + local reslist = {} + for i=begi, endi do + local item = proplist[i] + item.current = (i == pos) and true or nil + table.insert(reslist, item) + end + return count, reslist +end + +function get_playlist() + local pos = mp.get_property_number('playlist-pos', 0) + 1 + local count, limlist = limited_list('playlist', pos) + if count == 0 then + return texts.nolist + end + + local message = string.format(texts.playlist .. ' [%d/%d]:\n', pos, count) + for i, v in ipairs(limlist) do + local title = v.title + local _, filename = utils.split_path(v.filename) + if title == nil then + title = filename + end + message = string.format('%s %s %s\n', message, + (v.current and '●' or '○'), title) + end + return message +end + +function get_chapterlist() + local pos = mp.get_property_number('chapter', 0) + 1 + local count, limlist = limited_list('chapter-list', pos) + if count == 0 then + return texts.nochapter + end + + local message = string.format(texts.chapter.. ' [%d/%d]:\n', pos, count) + for i, v in ipairs(limlist) do + local time = mp.format_time(v.time) + local title = v.title + if title == nil then + title = string.format(texts.chapter .. ' %02d', i) + end + message = string.format('%s[%s] %s %s\n', message, time, + (v.current and '●' or '○'), title) + end + return message +end + +function show_message(text, duration) + + --print('text: '..text..' duration: ' .. duration) + if duration == nil then + duration = tonumber(mp.get_property('options/osd-duration')) / 1000 + elseif not type(duration) == 'number' then + print('duration: ' .. duration) + end + + -- cut the text short, otherwise the following functions + -- may slow down massively on huge input + text = string.sub(text, 0, 4000) + + -- replace actual linebreaks with ASS linebreaks + text = string.gsub(text, '\n', '\\N') + + state.message_text = text + + if not state.message_hide_timer then + state.message_hide_timer = mp.add_timeout(0, request_tick) + end + state.message_hide_timer:kill() + state.message_hide_timer.timeout = duration + state.message_hide_timer:resume() + request_tick() +end + +function render_message(ass) + if state.message_hide_timer and state.message_hide_timer:is_enabled() and + state.message_text + then + local _, lines = string.gsub(state.message_text, '\\N', '') + + local fontsize = tonumber(mp.get_property('options/osd-font-size')) + local outline = tonumber(mp.get_property('options/osd-border-size')) + local maxlines = math.ceil(osc_param.unscaled_y*0.75 / fontsize) + local counterscale = osc_param.playresy / osc_param.unscaled_y + + fontsize = fontsize * counterscale / math.max(0.65 + math.min(lines/maxlines, 1), 1) + outline = outline * counterscale / math.max(0.75 + math.min(lines/maxlines, 1)/2, 1) + + local style = '{\\bord' .. outline .. '\\fs' .. fontsize .. '}' + + + ass:new_event() + ass:append(style .. state.message_text) + else + state.message_text = nil + end +end + +-- +-- Initialisation and Layout +-- + +function new_element(name, type) + elements[name] = {} + elements[name].type = type + + -- add default stuff + elements[name].eventresponder = {} + elements[name].visible = true + elements[name].enabled = true + elements[name].softrepeat = false + elements[name].styledown = (type == 'button') + elements[name].state = {} + + if (type == 'slider') then + elements[name].slider = {min = {value = 0}, max = {value = 100}} + end + + + return elements[name] +end + +function add_layout(name) + if not (elements[name] == nil) then + -- new layout + elements[name].layout = {} + + -- set layout defaults + elements[name].layout.layer = 50 + elements[name].layout.alpha = {[1] = 0, [2] = 255, [3] = 255, [4] = 255} + + if (elements[name].type == 'button') then + elements[name].layout.button = { + maxchars = nil, + } + elseif (elements[name].type == 'slider') then + -- slider defaults + elements[name].layout.slider = { + border = 1, + gap = 1, + nibbles_top = true, + nibbles_bottom = true, + adjust_tooltip = true, + tooltip_style = '', + tooltip_an = 2, + alpha = {[1] = 0, [2] = 255, [3] = 88, [4] = 255}, + } + elseif (elements[name].type == 'box') then + elements[name].layout.box = {radius = 0, hexagon = false} + end + + return elements[name].layout + else + msg.error('Can\'t add_layout to element \''..name..'\', doesn\'t exist.') + end +end + +-- Window Controls +function window_controls() + local wc_geo = { + x = 0, + y = 32, + an = 1, + w = osc_param.playresx, + h = 32, + } + + local controlbox_w = window_control_box_width + local titlebox_w = wc_geo.w - controlbox_w + + -- Default alignment is 'right' + local controlbox_left = wc_geo.w - controlbox_w + local titlebox_left = wc_geo.x + local titlebox_right = wc_geo.w - controlbox_w + + add_area('window-controls', + get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, + controlbox_w, wc_geo.h)) + + local lo + + local button_y = wc_geo.y - (wc_geo.h / 2) + local first_geo = + {x = controlbox_left + 27, y = button_y, an = 5, w = 40, h = wc_geo.h} + local second_geo = + {x = controlbox_left + 69, y = button_y, an = 5, w = 40, h = wc_geo.h} + local third_geo = + {x = controlbox_left + 115, y = button_y, an = 5, w = 40, h = wc_geo.h} + + -- Window control buttons use symbols in the custom mpv osd font + -- because the official unicode codepoints are sufficiently + -- exotic that a system might lack an installed font with them, + -- and libass will complain that they are not present in the + -- default font, even if another font with them is available. + + -- Close: ?? + ne = new_element('close', 'button') + ne.content = '\238\132\149' + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('quit') end + lo = add_layout('close') + lo.geometry = third_geo + lo.style = osc_styles.WinCtrl + lo.alpha[3] = 0 + + -- Minimize: ?? + ne = new_element('minimize', 'button') + ne.content = '\\n\238\132\146' + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('cycle', 'window-minimized') end + lo = add_layout('minimize') + lo.geometry = first_geo + lo.style = osc_styles.WinCtrl + lo.alpha[3] = 0 + + -- Maximize: ?? /?? + ne = new_element('maximize', 'button') + if state.maximized or state.fullscreen then + ne.content = '\238\132\148' + else + ne.content = '\238\132\147' + end + ne.eventresponder['mbtn_left_up'] = + function () + if state.fullscreen then + mp.commandv('cycle', 'fullscreen') + else + mp.commandv('cycle', 'window-maximized') + end + end + lo = add_layout('maximize') + lo.geometry = second_geo + lo.style = osc_styles.WinCtrl + lo.alpha[3] = 0 +end + +-- +-- Layouts +-- + +local layouts = {} + +-- Default layout +layouts = function () + + local osc_geo = {w, h} + + osc_geo.w = osc_param.playresx + osc_geo.h = 180 + + -- origin of the controllers, left/bottom corner + local posX = 0 + local posY = osc_param.playresy + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area('input', get_hitbox_coords(posX, posY, 1, osc_geo.w, 104)) + + -- area for show/hide + add_area('showhide', 0, 0, osc_param.playresx, osc_param.playresy) + + -- fetch values + local osc_w, osc_h= + osc_geo.w, osc_geo.h + + -- + -- Controller Background + -- + local lo + + new_element('TransBg', 'box') + lo = add_layout('TransBg') + lo.geometry = {x = posX, y = posY, an = 7, w = osc_w, h = 1} + lo.style = osc_styles.TransBg + lo.layer = 10 + lo.alpha[3] = 0 + + -- + -- Alignment + -- + local refX = osc_w / 2 + local refY = posY + local geo + + -- + -- Seekbar + -- + new_element('seekbarbg', 'box') + lo = add_layout('seekbarbg') + lo.geometry = {x = refX , y = refY - 96 , an = 5, w = osc_geo.w - 50, h = 2} + lo.layer = 13 + lo.style = osc_styles.SeekbarBg + lo.alpha[1] = 128 + lo.alpha[3] = 128 + + lo = add_layout('seekbar') + lo.geometry = {x = refX, y = refY - 96 , an = 5, w = osc_geo.w - 50, h = 16} + lo.style = osc_styles.SeekbarFg + lo.slider.gap = 7 + lo.slider.tooltip_style = osc_styles.Tooltip + lo.slider.tooltip_an = 2 + -- + -- Volumebar + -- + lo = new_element('volumebarbg', 'box') + lo.visible = (osc_param.playresx >= 750) and user_opts.volumecontrol + lo = add_layout('volumebarbg') + lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 2} + lo.layer = 13 + lo.style = osc_styles.VolumebarBg + + + lo = add_layout('volumebar') + lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 8} + lo.style = osc_styles.VolumebarFg + lo.slider.gap = 3 + lo.slider.tooltip_style = osc_styles.Tooltip + lo.slider.tooltip_an = 2 + + -- buttons + lo = add_layout('pl_prev') + lo.geometry = {x = refX - 120, y = refY - 40 , an = 5, w = 30, h = 24} + lo.style = osc_styles.Ctrl2 + + lo = add_layout('skipback') + lo.geometry = {x = refX - 60, y = refY - 40 , an = 5, w = 30, h = 24} + lo.style = osc_styles.Ctrl2 + + + lo = add_layout('playpause') + lo.geometry = {x = refX, y = refY - 40 , an = 5, w = 45, h = 45} + lo.style = osc_styles.Ctrl1 + + lo = add_layout('skipfrwd') + lo.geometry = {x = refX + 60, y = refY - 40 , an = 5, w = 30, h = 24} + lo.style = osc_styles.Ctrl2 + + lo = add_layout('pl_next') + lo.geometry = {x = refX + 120, y = refY - 40 , an = 5, w = 30, h = 24} + lo.style = osc_styles.Ctrl2 + + + -- Time + lo = add_layout('tc_left') + lo.geometry = {x = 25, y = refY - 84, an = 7, w = 64, h = 20} + lo.style = osc_styles.Time + + + lo = add_layout('tc_right') + lo.geometry = {x = osc_geo.w - 25 , y = refY -84, an = 9, w = 64, h = 20} + lo.style = osc_styles.Time + + lo = add_layout('cy_audio') + lo.geometry = {x = 37, y = refY - 40, an = 5, w = 24, h = 24} + lo.style = osc_styles.Ctrl3 + lo.visible = (osc_param.playresx >= 540) + + lo = add_layout('cy_sub') + lo.geometry = {x = 87, y = refY - 40, an = 5, w = 24, h = 24} + lo.style = osc_styles.Ctrl3 + lo.visible = (osc_param.playresx >= 600) + + lo = add_layout('vol_ctrl') + lo.geometry = {x = 137, y = refY - 40, an = 5, w = 24, h = 24} + lo.style = osc_styles.Ctrl3 + lo.visible = (osc_param.playresx >= 650) + + lo = add_layout('tog_fs') + lo.geometry = {x = osc_geo.w - 37, y = refY - 40, an = 5, w = 24, h = 24} + lo.style = osc_styles.Ctrl3 + lo.visible = (osc_param.playresx >= 540) + + lo = add_layout('tog_info') + lo.geometry = {x = osc_geo.w - 87, y = refY - 40, an = 5, w = 24, h = 24} + lo.style = osc_styles.Ctrl3 + lo.visible = (osc_param.playresx >= 600) + + geo = { x = 25, y = refY - 132, an = 1, w = osc_geo.w - 50, h = 48 } + lo = add_layout('title') + lo.geometry = geo + lo.style = string.format('%s{\\clip(%f,%f,%f,%f)}', osc_styles.Title, + geo.x, geo.y - geo.h, geo.x + geo.w , geo.y) + lo.alpha[3] = 0 + lo.button.maxchars = geo.w / 23 +end + +-- Validate string type user options +function validate_user_opts() + if user_opts.windowcontrols ~= 'auto' and + user_opts.windowcontrols ~= 'yes' and + user_opts.windowcontrols ~= 'no' then + msg.warn('windowcontrols cannot be \'' .. + user_opts.windowcontrols .. '\'. Ignoring.') + user_opts.windowcontrols = 'auto' + end +end + +function update_options(list) + validate_user_opts() + request_tick() + visibility_mode(user_opts.visibility, true) + request_init() +end + +-- OSC INIT +function osc_init() + msg.debug('osc_init') + + -- set canvas resolution according to display aspect and scaling setting + local baseResY = 720 + local display_w, display_h, display_aspect = mp.get_osd_size() + local scale = 1 + + if (mp.get_property('video') == 'no') then -- dummy/forced window + scale = user_opts.scaleforcedwindow + elseif state.fullscreen then + scale = user_opts.scalefullscreen + else + scale = user_opts.scalewindowed + end + + if user_opts.vidscale then + osc_param.unscaled_y = baseResY + else + osc_param.unscaled_y = display_h + end + osc_param.playresy = osc_param.unscaled_y / scale + if (display_aspect > 0) then + osc_param.display_aspect = display_aspect + end + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + + -- stop seeking with the slider to prevent skipping files + state.active_element = nil + + elements = {} + + -- some often needed stuff + local pl_count = mp.get_property_number('playlist-count', 0) + local have_pl = (pl_count > 1) + local pl_pos = mp.get_property_number('playlist-pos', 0) + 1 + local have_ch = (mp.get_property_number('chapters', 0) > 0) + local loop = mp.get_property('loop-playlist', 'no') + + local ne + + -- playlist buttons + -- prev + ne = new_element('pl_prev', 'button') + + ne.content = '\xEF\x8E\xB5' + ne.enabled = (pl_pos > 1) or (loop ~= 'no') + ne.eventresponder['mbtn_left_up'] = + function () + mp.commandv('playlist-prev', 'weak') + end + ne.eventresponder['mbtn_right_up'] = + function () show_message(get_playlist()) end + + --next + ne = new_element('pl_next', 'button') + + ne.content = '\xEF\x8E\xB4' + ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= 'no') + ne.eventresponder['mbtn_left_up'] = + function () + mp.commandv('playlist-next', 'weak') + end + ne.eventresponder['mbtn_right_up'] = + function () show_message(get_playlist()) end + + + --play control buttons + --playpause + ne = new_element('playpause', 'button') + + ne.content = function () + if mp.get_property('pause') == 'no' then + return ('\xEF\x8E\xA7') + else + return ('\xEF\x8E\xAA') + end + end + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('cycle', 'pause') end + --ne.eventresponder['mbtn_right_up'] = + -- function () mp.commandv('script-binding', 'open-file-dialog') end + + --skipback + ne = new_element('skipback', 'button') + + ne.softrepeat = true + ne.content = '\xEF\x8E\xA0' + ne.eventresponder['mbtn_left_down'] = + --function () mp.command('seek -5') end + function () mp.commandv('seek', -5, 'relative', 'keyframes') end + ne.eventresponder['shift+mbtn_left_down'] = + function () mp.commandv('frame-back-step') end + ne.eventresponder['mbtn_right_down'] = + --function () mp.command('seek -60') end + function () mp.commandv('seek', -60, 'relative', 'keyframes') end + + --skipfrwd + ne = new_element('skipfrwd', 'button') + + ne.softrepeat = true + ne.content = '\xEF\x8E\x9F' + ne.eventresponder['mbtn_left_down'] = + --function () mp.command('seek +5') end + function () mp.commandv('seek', 5, 'relative', 'keyframes') end + ne.eventresponder['shift+mbtn_left_down'] = + function () mp.commandv('frame-step') end + ne.eventresponder['mbtn_right_down'] = + --function () mp.command('seek +60') end + function () mp.commandv('seek', 60, 'relative', 'keyframes') end + + -- + update_tracklist() + + --cy_audio + ne = new_element('cy_audio', 'button') + ne.enabled = (#tracks_osc.audio > 0) + ne.visible = (osc_param.playresx >= 540) + ne.content = '\xEF\x8E\xB7' + ne.tooltip_style = osc_styles.Tooltip + ne.tooltipF = function () + local msg = texts.off + if not (get_track('audio') == 0) then + msg = (texts.audio .. ' [' .. get_track('audio') .. ' ∕ ' .. #tracks_osc.audio .. '] ') + local prop = mp.get_property('current-tracks/audio/lang') + if not prop then + prop = texts.na + end + msg = msg .. '[' .. prop .. ']' + prop = mp.get_property('current-tracks/audio/title') + if prop then + msg = msg .. ' ' .. prop + end + return msg + end + return msg + end + ne.eventresponder['mbtn_left_up'] = + function () set_track('audio', 1) end + ne.eventresponder['mbtn_right_up'] = + function () set_track('audio', -1) end + ne.eventresponder['mbtn_mid_up'] = + function () show_message(get_tracklist('audio')) end + + --cy_sub + ne = new_element('cy_sub', 'button') + ne.enabled = (#tracks_osc.sub > 0) + ne.visible = (osc_param.playresx >= 600) + ne.content = '\xEF\x8F\x93' + ne.tooltip_style = osc_styles.Tooltip + ne.tooltipF = function () + local msg = texts.off + if not (get_track('sub') == 0) then + msg = (texts.subtitle .. ' [' .. get_track('sub') .. ' ∕ ' .. #tracks_osc.sub .. '] ') + local prop = mp.get_property('current-tracks/sub/lang') + if not prop then + prop = texts.na + end + msg = msg .. '[' .. prop .. ']' + prop = mp.get_property('current-tracks/sub/title') + if prop then + msg = msg .. ' ' .. prop + end + return msg + end + return msg + end + ne.eventresponder['mbtn_left_up'] = + function () set_track('sub', 1) end + ne.eventresponder['mbtn_right_up'] = + function () set_track('sub', -1) end + ne.eventresponder['mbtn_mid_up'] = + function () show_message(get_tracklist('sub')) end + + -- vol_ctrl + ne = new_element('vol_ctrl', 'button') + ne.enabled = (get_track('audio')>0) + ne.visible = (osc_param.playresx >= 650) and user_opts.volumecontrol + ne.content = function () + if (state.mute) then + return ('\xEF\x8E\xBB') + else + return ('\xEF\x8E\xBC') + end + end + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('cycle', 'mute') end + + --tog_fs + ne = new_element('tog_fs', 'button') + ne.content = function () + if (state.fullscreen) then + return ('\xEF\x85\xAC') + else + return ('\xEF\x85\xAD') + end + end + ne.visible = (osc_param.playresx >= 540) + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('cycle', 'fullscreen') end + + --tog_info + ne = new_element('tog_info', 'button') + ne.content = '' + ne.visible = (osc_param.playresx >= 600) + ne.eventresponder['mbtn_left_up'] = + function () mp.commandv('script-binding', 'stats/display-stats-toggle') end + + -- title + ne = new_element('title', 'button') + ne.content = function () + local title = mp.command_native({'expand-text', user_opts.title}) + if state.paused then + title = title:gsub('\\n', ' '):gsub('\\$', ''):gsub('{','\\{') + else + title = ' ' + end + return not (title == '') and title or ' ' + end + ne.visible = osc_param.playresy >= 320 and user_opts.showtitle + + --seekbar + ne = new_element('seekbar', 'slider') + + ne.enabled = not (mp.get_property('percent-pos') == nil) + ne.slider.markerF = function () + local duration = mp.get_property_number('duration', nil) + if not (duration == nil) then + local chapters = mp.get_property_native('chapter-list', {}) + local markers = {} + for n = 1, #chapters do + markers[n] = (chapters[n].time / duration * 100) + end + return markers + else + return {} + end + end + ne.slider.posF = + function () return mp.get_property_number('percent-pos', nil) end + ne.slider.tooltipF = function (pos) + local duration = mp.get_property_number('duration', nil) + if not ((duration == nil) or (pos == nil)) then + possec = duration * (pos / 100) + return mp.format_time(possec) + else + return '' + end + end + ne.slider.seekRangesF = function() + if not user_opts.seekrange then + return nil + end + local cache_state = state.cache_state + if not cache_state then + return nil + end + local duration = mp.get_property_number('duration', nil) + if (duration == nil) or duration <= 0 then + return nil + end + local ranges = cache_state['seekable-ranges'] + if #ranges == 0 then + return nil + end + local nranges = {} + for _, range in pairs(ranges) do + nranges[#nranges + 1] = { + ['start'] = 100 * range['start'] / duration, + ['end'] = 100 * range['end'] / duration, + } + end + return nranges + end + ne.eventresponder['mouse_move'] = --keyframe seeking when mouse is dragged + function (element) + if not element.state.mbtnleft then return end -- allow drag for mbtnleft only! + -- mouse move events may pile up during seeking and may still get + -- sent when the user is done seeking, so we need to throw away + -- identical seeks + local seekto = get_slider_value(element) + if (element.state.lastseek == nil) or + (not (element.state.lastseek == seekto)) then + local flags = 'absolute-percent' + if not user_opts.seekbarkeyframes then + flags = flags .. '+exact' + end + mp.commandv('seek', seekto, flags) + element.state.lastseek = seekto + end + + end + ne.eventresponder['mbtn_left_down'] = --exact seeks on single clicks + function (element) + mp.commandv('seek', get_slider_value(element), 'absolute-percent', 'exact') + element.state.mbtnleft = true + end + ne.eventresponder['mbtn_left_up'] = + function (element) element.state.mbtnleft = false end + ne.eventresponder['mbtn_right_down'] = --seeks to chapter start + function (element) + local duration = mp.get_property_number('duration', nil) + if not (duration == nil) then + local chapters = mp.get_property_native('chapter-list', {}) + if #chapters > 0 then + local pos = get_slider_value(element) + local ch = #chapters + for n = 1, ch do + if chapters[n].time / duration * 100 >= pos then + ch = n - 1 + break + end + end + mp.commandv('set', 'chapter', ch - 1) + --if chapters[ch].title then show_message(chapters[ch].time) end + end + end + end + ne.eventresponder['reset'] = + function (element) element.state.lastseek = nil end + + --volumebar + ne = new_element('volumebar', 'slider') + ne.visible = (osc_param.playresx >= 700) and user_opts.volumecontrol + ne.enabled = (get_track('audio')>0) + ne.slider.markerF = function () + return {} + end + ne.slider.seekRangesF = function() + return nil + end + ne.slider.posF = + function () + local val = mp.get_property_number('volume', nil) + return val*val/100 + end + ne.eventresponder['mouse_move'] = + function (element) + if not element.state.mbtnleft then return end -- allow drag for mbtnleft only! + local seekto = get_slider_value(element) + if (element.state.lastseek == nil) or + (not (element.state.lastseek == seekto)) then + mp.commandv('set', 'volume', 10*math.sqrt(seekto)) + element.state.lastseek = seekto + end + end + ne.eventresponder['mbtn_left_down'] = --exact seeks on single clicks + function (element) + local seekto = get_slider_value(element) + mp.commandv('set', 'volume', 10*math.sqrt(seekto)) + element.state.mbtnleft = true + end + ne.eventresponder['mbtn_left_up'] = + function (element) element.state.mbtnleft = false end + ne.eventresponder['reset'] = + function (element) element.state.lastseek = nil end + + -- tc_left (current pos) + ne = new_element('tc_left', 'button') + ne.content = function () return (mp.get_property_osd('playback-time')) end + + -- tc_right (total/remaining time) + ne = new_element('tc_right', 'button') + ne.content = function () + if (mp.get_property_number('duration', 0) <= 0) then return '--:--:--' end + if (state.rightTC_trem) then + return ('-'..mp.get_property_osd('playtime-remaining')) + else + return (mp.get_property_osd('duration')) + end + end + ne.eventresponder['mbtn_left_up'] = + function () state.rightTC_trem = not state.rightTC_trem end + + -- load layout + layouts() + + -- load window controls + if window_controls_enabled() then + window_controls() + end + + --do something with the elements + prepare_elements() +end + +function shutdown() + +end + +-- +-- Other important stuff +-- + + +function show_osc() + -- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding + if not state.enabled then return end + + msg.trace('show_osc') + --remember last time of invocation (mouse move) + state.showtime = mp.get_time() + + osc_visible(true) + + if (user_opts.fadeduration > 0) then + state.anitype = nil + end +end + +function hide_osc() + msg.trace('hide_osc') + if not state.enabled then + -- typically hide happens at render() from tick(), but now tick() is + -- no-op and won't render again to remove the osc, so do that manually. + state.osc_visible = false + render_wipe() + elseif (user_opts.fadeduration > 0) then + if not(state.osc_visible == false) then + state.anitype = 'out' + request_tick() + end + else + osc_visible(false) + end +end + +function osc_visible(visible) + if state.osc_visible ~= visible then + state.osc_visible = visible + end + request_tick() +end + +function pause_state(name, enabled) + state.paused = enabled + if user_opts.showtitle then + if enabled then + state.lastvisibility = user_opts.visibility + visibility_mode("always", true) + show_osc() + else + visibility_mode(state.lastvisibility, true) + end + end + request_tick() +end + +function cache_state(name, st) + state.cache_state = st + request_tick() +end + +-- Request that tick() is called (which typically re-renders the OSC). +-- The tick is then either executed immediately, or rate-limited if it was +-- called a small time ago. +function request_tick() + if state.tick_timer == nil then + state.tick_timer = mp.add_timeout(0, tick) + end + + if not state.tick_timer:is_enabled() then + local now = mp.get_time() + local timeout = tick_delay - (now - state.tick_last_time) + if timeout < 0 then + timeout = 0 + end + state.tick_timer.timeout = timeout + state.tick_timer:resume() + end +end + +function mouse_leave() + if get_hidetimeout() >= 0 then + hide_osc() + end + -- reset mouse position + state.last_mouseX, state.last_mouseY = nil, nil + state.mouse_in_window = false +end + +function request_init() + state.initREQ = true + request_tick() +end + +-- Like request_init(), but also request an immediate update +function request_init_resize() + request_init() + -- ensure immediate update + state.tick_timer:kill() + state.tick_timer.timeout = 0 + state.tick_timer:resume() +end + +function render_wipe() + msg.trace('render_wipe()') + state.osd:remove() +end + +function render() + msg.trace('rendering') + local current_screen_sizeX, current_screen_sizeY, aspect = mp.get_osd_size() + local mouseX, mouseY = get_virt_mouse_pos() + local now = mp.get_time() + + -- check if display changed, if so request reinit + if not (state.mp_screen_sizeX == current_screen_sizeX + and state.mp_screen_sizeY == current_screen_sizeY) then + + request_init_resize() + + state.mp_screen_sizeX = current_screen_sizeX + state.mp_screen_sizeY = current_screen_sizeY + end + + -- init management + if state.initREQ then + osc_init() + state.initREQ = false + + -- store initial mouse position + if (state.last_mouseX == nil or state.last_mouseY == nil) + and not (mouseX == nil or mouseY == nil) then + + state.last_mouseX, state.last_mouseY = mouseX, mouseY + end + end + + + -- fade animation + if not(state.anitype == nil) then + + if (state.anistart == nil) then + state.anistart = now + end + + if (now < state.anistart + (user_opts.fadeduration/1000)) then + + if (state.anitype == 'in') then --fade in + osc_visible(true) + state.animation = scale_value(state.anistart, + (state.anistart + (user_opts.fadeduration/1000)), + 255, 0, now) + elseif (state.anitype == 'out') then --fade out + state.animation = scale_value(state.anistart, + (state.anistart + (user_opts.fadeduration/1000)), + 0, 255, now) + end + + else + if (state.anitype == 'out') then + osc_visible(false) + end + state.anistart = nil + state.animation = nil + state.anitype = nil + end + else + state.anistart = nil + state.animation = nil + state.anitype = nil + end + + --mouse show/hide area + for k,cords in pairs(osc_param.areas['showhide']) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide') + end + if osc_param.areas['showhide_wc'] then + for k,cords in pairs(osc_param.areas['showhide_wc']) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide_wc') + end + else + set_virt_mouse_area(0, 0, 0, 0, 'showhide_wc') + end + do_enable_keybindings() + + --mouse input area + local mouse_over_osc = false + + for _,cords in ipairs(osc_param.areas['input']) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'input') + end + if state.osc_visible ~= state.input_enabled then + if state.osc_visible then + mp.enable_key_bindings('input') + else + mp.disable_key_bindings('input') + end + state.input_enabled = state.osc_visible + end + + if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then + mouse_over_osc = true + end + end + + if osc_param.areas['window-controls'] then + for _,cords in ipairs(osc_param.areas['window-controls']) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'window-controls') + mp.enable_key_bindings('window-controls') + else + mp.disable_key_bindings('window-controls') + end + + if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then + mouse_over_osc = true + end + end + end + + if osc_param.areas['window-controls-title'] then + for _,cords in ipairs(osc_param.areas['window-controls-title']) do + if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then + mouse_over_osc = true + end + end + end + + -- autohide + if not (state.showtime == nil) and (get_hidetimeout() >= 0) then + local timeout = state.showtime + (get_hidetimeout()/1000) - now + if timeout <= 0 then + if (state.active_element == nil) and not (mouse_over_osc) then + hide_osc() + end + else + -- the timer is only used to recheck the state and to possibly run + -- the code above again + if not state.hide_timer then + state.hide_timer = mp.add_timeout(0, tick) + end + state.hide_timer.timeout = timeout + -- re-arm + state.hide_timer:kill() + state.hide_timer:resume() + end + end + + + -- actual rendering + local ass = assdraw.ass_new() + + -- Messages + render_message(ass) + + -- actual OSC + if state.osc_visible then + render_elements(ass) + end + + -- submit + set_osd(osc_param.playresy * osc_param.display_aspect, + osc_param.playresy, ass.text) +end + +-- +-- Eventhandling +-- + +local function element_has_action(element, action) + return element and element.eventresponder and + element.eventresponder[action] +end + +function process_event(source, what) + local action = string.format('%s%s', source, + what and ('_' .. what) or '') + + if what == 'down' or what == 'press' then + + for n = 1, #elements do + + if mouse_hit(elements[n]) and + elements[n].eventresponder and + (elements[n].eventresponder[source .. '_up'] or + elements[n].eventresponder[action]) then + + if what == 'down' then + state.active_element = n + state.active_event_source = source + end + -- fire the down or press event if the element has one + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + + end + end + + elseif what == 'up' then + + if elements[state.active_element] then + local n = state.active_element + + if n == 0 then + --click on background (does not work) + elseif element_has_action(elements[n], action) and + mouse_hit(elements[n]) then + + elements[n].eventresponder[action](elements[n]) + end + + --reset active element + if element_has_action(elements[n], 'reset') then + elements[n].eventresponder['reset'](elements[n]) + end + + end + state.active_element = nil + state.mouse_down_counter = 0 + + elseif source == 'mouse_move' then + + state.mouse_in_window = true + + local mouseX, mouseY = get_virt_mouse_pos() + if (user_opts.minmousemove == 0) or + (not ((state.last_mouseX == nil) or (state.last_mouseY == nil)) and + ((math.abs(mouseX - state.last_mouseX) >= user_opts.minmousemove) + or (math.abs(mouseY - state.last_mouseY) >= user_opts.minmousemove) + ) + ) then + show_osc() + end + state.last_mouseX, state.last_mouseY = mouseX, mouseY + + local n = state.active_element + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + request_tick() + end +end + +function show_logo() + local osd_w, osd_h = 640, 360 + local logo_x, logo_y = osd_w/2, osd_h/2-20 + local ass = assdraw.ass_new() + ass:new_event() + ass:pos(logo_x, logo_y) + ass:append('{\\1c&H8E348D&\\3c&H0&\\3a&H60&\\blur1\\bord0.5}') + ass:draw_start() + ass_draw_cir_cw(ass, 0, 0, 100) + ass:draw_stop() + + ass:new_event() + ass:pos(logo_x, logo_y) + ass:append('{\\1c&H632462&\\bord0}') + ass:draw_start() + ass_draw_cir_cw(ass, 6, -6, 75) + ass:draw_stop() + + ass:new_event() + ass:pos(logo_x, logo_y) + ass:append('{\\1c&HFFFFFF&\\bord0}') + ass:draw_start() + ass_draw_cir_cw(ass, -4, 4, 50) + ass:draw_stop() + + ass:new_event() + ass:pos(logo_x, logo_y) + ass:append('{\\1c&H632462&\\bord&}') + ass:draw_start() + ass:move_to(-20, -20) + ass:line_to(23.3, 5) + ass:line_to(-20, 30) + ass:draw_stop() + + ass:new_event() + ass:pos(logo_x, logo_y+110) + ass:an(8) + ass:append(texts.welcome) + set_osd(osd_w, osd_h, ass.text) +end + +-- called by mpv on every frame +function tick() + if (not state.enabled) then return end + + if (state.idle) then + show_logo() + -- render idle message + msg.trace('idle message') + + if state.showhide_enabled then + mp.disable_key_bindings('showhide') + mp.disable_key_bindings('showhide_wc') + state.showhide_enabled = false + end + + + elseif (state.fullscreen and user_opts.showfullscreen) + or (not state.fullscreen and user_opts.showwindowed) then + + -- render the OSC + render() + else + -- Flush OSD + set_osd(osc_param.playresy, osc_param.playresy, '') + end + + state.tick_last_time = mp.get_time() + + if state.anitype ~= nil then + request_tick() + end +end + +function do_enable_keybindings() + if state.enabled then + if not state.showhide_enabled then + mp.enable_key_bindings('showhide', 'allow-vo-dragging+allow-hide-cursor') + mp.enable_key_bindings('showhide_wc', 'allow-vo-dragging+allow-hide-cursor') + end + state.showhide_enabled = true + end +end + +function enable_osc(enable) + state.enabled = enable + if enable then + do_enable_keybindings() + else + hide_osc() -- acts immediately when state.enabled == false + if state.showhide_enabled then + mp.disable_key_bindings('showhide') + mp.disable_key_bindings('showhide_wc') + end + state.showhide_enabled = false + end +end + +validate_user_opts() + +mp.register_event('shutdown', shutdown) +mp.register_event('start-file', request_init) +mp.observe_property('track-list', nil, request_init) +mp.observe_property('playlist', nil, request_init) + +mp.register_script_message('osc-message', show_message) +mp.register_script_message('osc-chapterlist', function(dur) + show_message(get_chapterlist(), dur) +end) +mp.register_script_message('osc-playlist', function(dur) + show_message(get_playlist(), dur) +end) +mp.register_script_message('osc-tracklist', function(dur) + local msg = {} + for k,v in pairs(nicetypes) do + table.insert(msg, get_tracklist(k)) + end + show_message(table.concat(msg, '\n\n'), dur) +end) + +mp.observe_property('fullscreen', 'bool', + function(name, val) + state.fullscreen = val + request_init_resize() + end +) +mp.observe_property('mute', 'bool', + function(name, val) + state.mute = val + end +) +mp.observe_property('border', 'bool', + function(name, val) + state.border = val + request_init_resize() + end +) +mp.observe_property('window-maximized', 'bool', + function(name, val) + state.maximized = val + request_init_resize() + end +) +mp.observe_property('idle-active', 'bool', + function(name, val) + state.idle = val + request_tick() + end +) +mp.observe_property('pause', 'bool', pause_state) +mp.observe_property('demuxer-cache-state', 'native', cache_state) +mp.observe_property('vo-configured', 'bool', function(name, val) + request_tick() +end) +mp.observe_property('playback-time', 'number', function(name, val) + request_tick() +end) +mp.observe_property('osd-dimensions', 'native', function(name, val) + -- (we could use the value instead of re-querying it all the time, but then + -- we might have to worry about property update ordering) + request_init_resize() +end) + +-- mouse show/hide bindings +mp.set_key_bindings({ + {'mouse_move', function(e) process_event('mouse_move', nil) end}, + {'mouse_leave', mouse_leave}, +}, 'showhide', 'force') +mp.set_key_bindings({ + {'mouse_move', function(e) process_event('mouse_move', nil) end}, + {'mouse_leave', mouse_leave}, +}, 'showhide_wc', 'force') +do_enable_keybindings() + +--mouse input bindings +mp.set_key_bindings({ + {'mbtn_left', function(e) process_event('mbtn_left', 'up') end, + function(e) process_event('mbtn_left', 'down') end}, + {'mbtn_right', function(e) process_event('mbtn_right', 'up') end, + function(e) process_event('mbtn_right', 'down') end}, + {'mbtn_mid', function(e) process_event('mbtn_mid', 'up') end, + function(e) process_event('mbtn_mid', 'down') end}, + {'wheel_up', function(e) process_event('wheel_up', 'press') end}, + {'wheel_down', function(e) process_event('wheel_down', 'press') end}, + {'mbtn_left_dbl', 'ignore'}, + {'mbtn_right_dbl', 'ignore'}, +}, 'input', 'force') +mp.enable_key_bindings('input') + +mp.set_key_bindings({ + {'mbtn_left', function(e) process_event('mbtn_left', 'up') end, + function(e) process_event('mbtn_left', 'down') end}, +}, 'window-controls', 'force') +mp.enable_key_bindings('window-controls') + +function get_hidetimeout() + if user_opts.visibility == 'always' then + return -1 -- disable autohide + end + return user_opts.hidetimeout +end + +function always_on(val) + if state.enabled then + if val then + show_osc() + else + hide_osc() + end + end +end + +-- mode can be auto/always/never/cycle +-- the modes only affect internal variables and not stored on its own. +function visibility_mode(mode, no_osd) + if mode == 'auto' then + always_on(false) + enable_osc(true) + elseif mode == 'always' then + enable_osc(true) + always_on(true) + elseif mode == 'never' then + enable_osc(false) + else + msg.warn('Ignoring unknown visibility mode \"' .. mode .. '\"') + return + end + + user_opts.visibility = mode + + if not no_osd and tonumber(mp.get_property('osd-level')) >= 1 then + mp.osd_message('OSC visibility: ' .. mode) + end + + -- Reset the input state on a mode change. The input state will be + -- recalcuated on the next render cycle, except in 'never' mode where it + -- will just stay disabled. + mp.disable_key_bindings('input') + mp.disable_key_bindings('window-controls') + state.input_enabled = false + request_tick() +end + +visibility_mode(user_opts.visibility, true) +mp.register_script_message('osc-visibility', visibility_mode) +mp.add_key_binding(nil, 'visibility', function() visibility_mode('cycle') end) + +set_virt_mouse_area(0, 0, 0, 0, 'input') +set_virt_mouse_area(0, 0, 0, 0, 'window-controls') diff --git a/mpv/.config/mpv/scripts/playlistmanager.lua b/mpv/.config/mpv/scripts/playlistmanager.lua new file mode 100644 index 0000000..9b0f1a5 --- /dev/null +++ b/mpv/.config/mpv/scripts/playlistmanager.lua @@ -0,0 +1,1125 @@ +local settings = { + + -- #### FUNCTIONALITY SETTINGS + + --navigation keybindings force override only while playlist is visible + --if "no" then you can display the playlist by any of the navigation keys + dynamic_binds = true, + + -- main key + key_showplaylist = "SHIFT+ENTER", + + -- dynamic keys - to bind multiple keys separate them by a space + key_moveup = "UP", + key_movedown = "DOWN", + key_movepageup = "PGUP", + key_movepagedown = "PGDWN", + key_movebegin = "HOME", + key_moveend = "END", + key_selectfile = "RIGHT LEFT", + key_unselectfile = "", + key_playfile = "ENTER", + key_removefile = "BS", + key_closeplaylist = "ESC", + + -- extra functionality keys + key_sortplaylist = "", + key_shuffleplaylist = "", + key_reverseplaylist = "", + key_loadfiles = "", + key_saveplaylist = "", + + --replaces matches on filenames based on extension, put as empty string to not replace anything + --replace rules are executed in provided order + --replace rule key is the pattern and value is the replace value + --uses :gsub('pattern', 'replace'), read more http://lua-users.org/wiki/StringLibraryTutorial + --'all' will match any extension or protocol if it has one + --uses json and parses it into a lua table to be able to support .conf file + + filename_replace = "", + +--[=====[ START OF SAMPLE REPLACE, to use remove start and end line + --Sample replace: replaces underscore to space on all files + --for mp4 and webm; remove extension, remove brackets and surrounding whitespace, change dot between alphanumeric to space + filename_replace = [[ + [ + { + "ext": { "all": true}, + "rules": [ + { "_" : " " } + ] + },{ + "ext": { "mp4": true, "mkv": true }, + "rules": [ + { "^(.+)%..+$": "%1" }, + { "%s*[%[%(].-[%]%)]%s*": "" }, + { "(%w)%.(%w)": "%1 %2" } + ] + },{ + "protocol": { "http": true, "https": true }, + "rules": [ + { "^%a+://w*%.?": "" } + ] + } + ] + ]], +--END OF SAMPLE REPLACE ]=====] + + --json array of filetypes to search from directory + loadfiles_filetypes = [[ + [ + "jpg", "jpeg", "png", "tif", "tiff", "gif", "webp", "svg", "bmp", + "mp3", "wav", "ogm", "flac", "m4a", "wma", "ogg", "opus", + "mkv", "avi", "mp4", "ogv", "webm", "rmvb", "flv", "wmv", "mpeg", "mpg", "m4v", "3gp" + ] + ]], + + --loadfiles at startup if 1 or more items in playlist + loadfiles_on_start = false, + -- loadfiles from working directory on idle startup + loadfiles_on_idle_start = false, + --always put loaded files after currently playing file + loadfiles_always_append = false, + + --sort playlist on mpv start + sortplaylist_on_start = false, + + --sort playlist when files are added to playlist + sortplaylist_on_file_add = false, + + --use alphanumerical sort + alphanumsort = true, + + --"linux | windows | auto" + system = "auto", + + --Use ~ for home directory. Leave as empty to use mpv/playlists + playlist_savepath = "", + + --save playlist automatically after current file was unloaded + save_playlist_on_file_end = false, + + + --show playlist or filename every time a new file is loaded + --2 shows playlist, 1 shows current file(filename strip applied) as osd text, 0 shows nothing + --instead of using this you can also call script-message playlistmanager show playlist/filename + --ex. KEY playlist-next ; script-message playlistmanager show playlist + show_playlist_on_fileload = 0, + + --sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.) + --has the sideeffect of moving cursor if file happens to change when navigating + --good side is cursor always following current file when going back and forth files with playlist-next/prev + sync_cursor_on_load = true, + + --playlist open key will toggle visibility instead of refresh, best used with long timeout + open_toggles = true, + + --allow the playlist cursor to loop from end to start and vice versa + loop_cursor = true, + + --youtube-dl executable for title resolving if enabled, probably "youtube-dl" or "yt-dlp", can be absolute path + youtube_dl_executable = "youtube-dl", + + + --#### VISUAL SETTINGS + + --prefer to display titles for following files: "all", "url", "none". Sorting still uses filename. + prefer_titles = "url", + + --call youtube-dl to resolve the titles of urls in the playlist + resolve_titles = false, + + -- timeout in seconds for title resolving + resolve_title_timeout = 15, + + --osd timeout on inactivity, with high value on this open_toggles is good to be true + playlist_display_timeout = 5, + + --amount of entries to show before slicing. Optimal value depends on font/video size etc. + showamount = 16, + + --font size scales by window, if false requires larger font and padding sizes + scale_playlist_by_window=true, + --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua + --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1 + --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags + --undeclared tags will use default osd settings + --these styles will be used for the whole playlist + style_ass_tags = "{}", + --paddings from top left corner + text_padding_x = 10, + text_padding_y = 30, + + --set title of window with stripped name + set_title_stripped = false, + title_prefix = "", + title_suffix = " - mpv", + + --slice long filenames, and how many chars to show + slice_longfilenames = false, + slice_longfilenames_amount = 70, + + --Playlist header template + --%mediatitle or %filename = title or name of playing file + --%pos = position of playing file + --%cursor = position of navigation + --%plen = playlist length + --%N = newline + playlist_header = "[%cursor/%plen]", + + --Playlist file templates + --%pos = position of file with leading zeros + --%name = title or name of file + --%N = newline + --you can also use the ass tags mentioned above. For example: + -- selected_file="{\\c&HFF00FF&}➔ %name" | to add a color for selected file. However, if you + -- use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20) + normal_file = "○ %name", + hovered_file = "● %name", + selected_file = "➔ %name", + playing_file = "▷ %name", + playing_hovered_file = "▶ %name", + playing_selected_file = "➤ %name", + + + -- what to show when playlist is truncated + playlist_sliced_prefix = "...", + playlist_sliced_suffix = "...", + + --output visual feedback to OSD for tasks + display_osd_feedback = true, + + -- reset cursor navigation when playlist is not visible + reset_cursor_on_close = true, +} +local opts = require("mp.options") +opts.read_options(settings, "playlistmanager", function(list) update_opts(list) end) + +local utils = require("mp.utils") +local msg = require("mp.msg") +local assdraw = require("mp.assdraw") + + +--check os +if settings.system=="auto" then + local o = {} + if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then + settings.system = "windows" + else + settings.system = "linux" + end +end + +--global variables +local playlist_visible = false +local strippedname = nil +local path = nil +local directory = nil +local filename = nil +local pos = 0 +local plen = 0 +local cursor = 0 +--table for saved media titles for later if we prefer them +local url_table = {} +-- table for urls that we have request to be resolved to titles +local requested_urls = {} +--state for if we sort on playlist size change +local sort_watching = false + +local filetype_lookup = {} + +function update_opts(changelog) + msg.verbose('updating options') + + --parse filename json + if changelog.filename_replace then + if(settings.filename_replace~="") then + settings.filename_replace = utils.parse_json(settings.filename_replace) + else + settings.filename_replace = false + end + end + + --parse loadfiles json + if changelog.loadfiles_filetypes then + settings.loadfiles_filetypes = utils.parse_json(settings.loadfiles_filetypes) + + filetype_lookup = {} + --create loadfiles set + for _, ext in ipairs(settings.loadfiles_filetypes) do + filetype_lookup[ext] = true + end + end + + if changelog.resolve_titles then + resolve_titles() + end + + if changelog.playlist_display_timeout then + keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) + keybindstimer:kill() + end + + if playlist_visible then showplaylist() end +end + +update_opts({filename_replace = true, loadfiles_filetypes = true}) + +function on_loaded() + filename = mp.get_property("filename") + path = mp.get_property('path') + --if not a url then join path with working directory + if not path:match("^%a%a+:%/%/") then + path = utils.join_path(mp.get_property('working-directory'), path) + directory = utils.split_path(path) + else + directory = nil + end + + refresh_globals() + if settings.sync_cursor_on_load then + cursor=pos + --refresh playlist if cursor moved + if playlist_visible then draw_playlist() end + end + + local media_title = mp.get_property("media-title") + if path:match('^https?://') and not url_table[path] and path ~= media_title then + url_table[path] = media_title + end + + strippedname = stripfilename(mp.get_property('media-title')) + if settings.show_playlist_on_fileload == 2 then + showplaylist() + elseif settings.show_playlist_on_fileload == 1 then + mp.commandv('show-text', strippedname) + end + if settings.set_title_stripped then + mp.set_property("title", settings.title_prefix..strippedname..settings.title_suffix) + end + + local didload = false + if settings.loadfiles_on_start and plen == 1 then + didload = true --save reference for sorting + msg.info("Loading files from playing files directory") + playlist() + end + + --if we promised to sort files on launch do it + if promised_sort then + promised_sort = false + msg.info("Your playlist is sorted before starting playback") + if didload then sortplaylist() else sortplaylist(true) end + end + + --if we promised to listen and sort on playlist size increase do it + if promised_sort_watch then + promised_sort_watch = false + sort_watching = true + msg.info("Added files will be automatically sorted") + mp.observe_property('playlist-count', "number", autosort) + end +end + +function on_closed() + if settings.save_playlist_on_file_end then save_playlist() end + strippedname = nil + path = nil + directory = nil + filename = nil + if playlist_visible then showplaylist() end +end + +function refresh_globals() + pos = mp.get_property_number('playlist-pos', 0) + plen = mp.get_property_number('playlist-count', 0) +end + +function escapepath(dir, escapechar) + return string.gsub(dir, escapechar, '\\'..escapechar) +end + +--strip a filename based on its extension or protocol according to rules in settings +function stripfilename(pathfile, media_title) + if pathfile == nil then return '' end + local ext = pathfile:match("^.+%.(.+)$") + local protocol = pathfile:match("^(%a%a+)://") + if not ext then ext = "" end + local tmp = pathfile + if settings.filename_replace and not media_title then + for k,v in ipairs(settings.filename_replace) do + if ( v['ext'] and (v['ext'][ext] or (ext and not protocol and v['ext']['all'])) ) + or ( v['protocol'] and (v['protocol'][protocol] or (protocol and not ext and v['protocol']['all'])) ) then + for ruleindex, indexrules in ipairs(v['rules']) do + for rule, override in pairs(indexrules) do + tmp = tmp:gsub(rule, override) + end + end + end + end + end + if settings.slice_longfilenames and tmp:len()>settings.slice_longfilenames_amount+5 then + tmp = tmp:sub(1, settings.slice_longfilenames_amount).." ..." + end + return tmp +end + +--gets a nicename of playlist entry at 0-based position i +function get_name_from_index(i, notitle) + refresh_globals() + if plen <= i then msg.error("no index in playlist", i, "length", plen); return nil end + local _, name = nil + local title = mp.get_property('playlist/'..i..'/title') + local name = mp.get_property('playlist/'..i..'/filename') + + local should_use_title = settings.prefer_titles == 'all' or name:match('^https?://') and settings.prefer_titles == 'url' + --check if file has a media title stored or as property + if not title and should_use_title then + local mtitle = mp.get_property('media-title') + if i == pos and mp.get_property('filename') ~= mtitle then + if not url_table[name] then + url_table[name] = mtitle + end + title = mtitle + elseif url_table[name] then + title = url_table[name] + end + end + + --if we have media title use a more conservative strip + if title and not notitle and should_use_title then return stripfilename(title, true) end + + --remove paths if they exist, keeping protocols for stripping + if string.sub(name, 1, 1) == '/' or name:match("^%a:[/\\]") then + _, name = utils.split_path(name) + end + return stripfilename(name) +end + +function parse_header(string) + local esc_title = stripfilename(mp.get_property("media-title"), true):gsub("%%", "%%%%") + local esc_file = stripfilename(mp.get_property("filename")):gsub("%%", "%%%%") + return string:gsub("%%N", "\\N") + :gsub("%%pos", mp.get_property_number("playlist-pos",0)+1) + :gsub("%%plen", mp.get_property("playlist-count")) + :gsub("%%cursor", cursor+1) + :gsub("%%mediatitle", esc_title) + :gsub("%%filename", esc_file) + -- undo name escape + :gsub("%%%%", "%%") +end + +function parse_filename(string, name, index) + local base = tostring(plen):len() + local esc_name = stripfilename(name):gsub("%%", "%%%%") + return string:gsub("%%N", "\\N") + :gsub("%%pos", string.format("%0"..base.."d", index+1)) + :gsub("%%name", esc_name) + -- undo name escape + :gsub("%%%%", "%%") +end + +function parse_filename_by_index(index) + local template = settings.normal_file + + local is_idle = mp.get_property_native('idle-active') + local position = is_idle and -1 or pos + + if index == position then + if index == cursor then + if selection then + template = settings.playing_selected_file + else + template = settings.playing_hovered_file + end + else + template = settings.playing_file + end + elseif index == cursor then + if selection then + template = settings.selected_file + else + template = settings.hovered_file + end + end + + return parse_filename(template, get_name_from_index(index), index) +end + + +function draw_playlist() + refresh_globals() + local ass = assdraw.ass_new() + ass:pos(settings.text_padding_x, settings.text_padding_y) + ass:new_event() + ass:append(settings.style_ass_tags) + + if settings.playlist_header ~= "" then + ass:append(parse_header(settings.playlist_header).."\\N") + end + local start = cursor - math.floor(settings.showamount/2) + local showall = false + local showrest = false + if start<0 then start=0 end + if plen <= settings.showamount then + start=0 + showall=true + end + if start > math.max(plen-settings.showamount-1, 0) then + start=plen-settings.showamount + showrest=true + end + if start > 0 and not showall then ass:append(settings.playlist_sliced_prefix.."\\N") end + for index=start,start+settings.showamount-1,1 do + if index == plen then break end + + ass:append(parse_filename_by_index(index).."\\N") + if index == start+settings.showamount-1 and not showall and not showrest then + ass:append(settings.playlist_sliced_suffix) + end + end + local w, h = mp.get_osd_size() + if settings.scale_playlist_by_window then w,h = 0, 0 end + mp.set_osd_ass(w, h, ass.text) +end + +function toggle_playlist() + if settings.open_toggles then + if playlist_visible then + remove_keybinds() + return + end + end + showplaylist() +end + +function showplaylist(duration) + refresh_globals() + if plen == 0 then return end + playlist_visible = true + add_keybinds() + + draw_playlist() + keybindstimer:kill() + if duration then + keybindstimer = mp.add_periodic_timer(duration, remove_keybinds) + else + keybindstimer:resume() + end +end + +selection=nil +function selectfile() + refresh_globals() + if plen == 0 then return end + if not selection then + selection=cursor + else + selection=nil + end + showplaylist() +end + +function unselectfile() + selection=nil + showplaylist() +end + +function resetcursor() + cursor = mp.get_property_number('playlist-pos', 1) +end + +function removefile() + refresh_globals() + if plen == 0 then return end + selection = nil + if cursor==pos then mp.command("script-message unseenplaylist mark true \"playlistmanager avoid conflict when removing file\"") end + mp.commandv("playlist-remove", cursor) + if cursor==plen-1 then cursor = cursor - 1 end + showplaylist() +end + +function moveup() + refresh_globals() + if plen == 0 then return end + if cursor~=0 then + if selection then mp.commandv("playlist-move", cursor,cursor-1) end + cursor = cursor-1 + elseif settings.loop_cursor then + if selection then mp.commandv("playlist-move", cursor,plen) end + cursor = plen-1 + end + showplaylist() +end + +function movedown() + refresh_globals() + if plen == 0 then return end + if cursor ~= plen-1 then + if selection then mp.commandv("playlist-move", cursor,cursor+2) end + cursor = cursor + 1 + elseif settings.loop_cursor then + if selection then mp.commandv("playlist-move", cursor,0) end + cursor = 0 + end + showplaylist() +end + +function movepageup() + refresh_globals() + if plen == 0 or cursor == 0 then return end + local prev_cursor = cursor + cursor = cursor - settings.showamount + if cursor < 0 then cursor = 0 end + if selection then mp.commandv("playlist-move", prev_cursor, cursor) end + showplaylist() +end + +function movepagedown() + refresh_globals() + if plen == 0 or cursor == plen-1 then return end + local prev_cursor = cursor + cursor = cursor + settings.showamount + if cursor >= plen then cursor = plen-1 end + if selection then mp.commandv("playlist-move", prev_cursor, cursor+1) end + showplaylist() +end + +function movebegin() + refresh_globals() + if plen == 0 or cursor == 0 then return end + local prev_cursor = cursor + cursor = 0 + if selection then mp.commandv("playlist-move", prev_cursor, cursor) end + showplaylist() +end + +function moveend() + refresh_globals() + if plen == 0 or cursor == plen-1 then return end + local prev_cursor = cursor + cursor = plen-1 + if selection then mp.commandv("playlist-move", prev_cursor, cursor+1) end + showplaylist() +end + +function write_watch_later(force_write) + if mp.get_property_bool("save-position-on-quit") or force_write then + mp.command("write-watch-later-config") + end +end + +function playlist_next(force_write) + write_watch_later(force_write) + mp.commandv("playlist-next", "weak") +end + +function playlist_prev(force_write) + write_watch_later(force_write) + mp.commandv("playlist-prev", "weak") +end + +function playfile() + refresh_globals() + if plen == 0 then return end + selection = nil + local is_idle = mp.get_property_native('idle-active') + if cursor ~= pos or is_idle then + write_watch_later() + mp.set_property("playlist-pos", cursor) + else + if cursor~=plen-1 then + cursor = cursor + 1 + end + write_watch_later() + mp.commandv("playlist-next", "weak") + end + if settings.show_playlist_on_fileload ~= 2 then + remove_keybinds() + end +end + +function get_files_windows(dir) + local args = { + 'powershell', '-NoProfile', '-Command', [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + $path = "]]..dir..[[" + $escapedPath = [WildcardPattern]::Escape($path) + cd $escapedPath + + $list = (Get-ChildItem -File | Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(20) }) }).Name + $string = ($list -join "/") + $u8list = [System.Text.Encoding]::UTF8.GetBytes($string) + [Console]::OpenStandardOutput().Write($u8list, 0, $u8list.Length) + }]] + } + local process = utils.subprocess({ args = args, cancellable = false }) + return parse_files(process, '%/') +end + +function get_files_linux(dir) + local args = { 'ls', '-1pv', dir } + local process = utils.subprocess({ args = args, cancellable = false }) + return parse_files(process, '\n') +end + +function parse_files(res, delimiter) + if not res.error and res.status == 0 then + local valid_files = {} + for line in res.stdout:gmatch("[^"..delimiter.."]+") do + local ext = line:match("^.+%.(.+)$") + if ext and filetype_lookup[ext:lower()] then + table.insert(valid_files, line) + end + end + return valid_files, nil + else + return nil, res.error + end +end + +function get_playlist_filenames_set() + local filenames = {} + for n=0,plen-1,1 do + local filename = mp.get_property('playlist/'..n..'/filename') + local _, file = utils.split_path(filename) + filenames[file] = true + end + return filenames +end + +--Creates a playlist of all files in directory, will keep the order and position +--For exaple, Folder has 12 files, you open the 5th file and run this, the remaining 7 are added behind the 5th file and prior 4 files before it +function playlist(force_dir) + refresh_globals() + if not directory and plen > 0 then return end + local hasfile = true + if plen == 0 then + hasfile = false + dir = mp.get_property('working-directory') + else + dir = directory + end + if force_dir then dir = force_dir end + + local files, error + if settings.system == "linux" then + files, error = get_files_linux(dir) + else + files, error = get_files_windows(dir) + end + + local filenames = get_playlist_filenames_set() + local c, c2 = 0,0 + if files then + local cur = false + local filename = mp.get_property("filename") + for _, file in ipairs(files) do + local appendstr = "append" + if not hasfile then + cur = true + appendstr = "append-play" + hasfile = true + end + if filename == file then + cur = true + elseif filenames[file] then + -- skip files already in playlist + elseif cur == true or settings.loadfiles_always_append then + mp.commandv("loadfile", utils.join_path(dir, file), appendstr) + msg.info("Appended to playlist: " .. file) + c2 = c2 + 1 + else + mp.commandv("loadfile", utils.join_path(dir, file), appendstr) + msg.info("Prepended to playlist: " .. file) + mp.commandv("playlist-move", mp.get_property_number("playlist-count", 1)-1, c) + c = c + 1 + end + end + if c2 > 0 or c>0 then + mp.osd_message("Added "..c + c2.." files to playlist") + else + mp.osd_message("No additional files found") + end + cursor = mp.get_property_number('playlist-pos', 1) + else + msg.error("Could not scan for files: "..(error or "")) + end + if sort_watching then + msg.info("Ignoring directory structure and using playlist sort") + sortplaylist() + end + refresh_globals() + if playlist_visible then showplaylist() end + return c + c2 +end + +function parse_home(path) + if not path:find("^~") then + return path + end + local home_dir = os.getenv("HOME") or os.getenv("USERPROFILE") + if not home_dir then + local drive = os.getenv("HOMEDRIVE") + local path = os.getenv("HOMEPATH") + if drive and path then + home_dir = utils.join_path(drive, path) + else + msg.error("Couldn't find home dir.") + return nil + end + end + local result = path:gsub("^~", home_dir) + return result +end + +local interactive_save = false +function activate_playlist_save() + if interactive_save then + remove_keybinds() + mp.command("script-message playlistmanager-save-interactive \"start interactive filenaming process\"") + else + save_playlist() + end +end + +--saves the current playlist into a m3u file +function save_playlist(filename) + local length = mp.get_property_number('playlist-count', 0) + if length == 0 then return end + + --get playlist save path + local savepath + if settings.playlist_savepath == nil or settings.playlist_savepath == "" then + savepath = mp.command_native({"expand-path", "~~home/"}).."/playlists" + else + savepath = parse_home(settings.playlist_savepath) + if savepath == nil then return end + end + + --create savepath if it doesn't exist + if utils.readdir(savepath) == nil then + local windows_args = {'powershell', '-NoProfile', '-Command', 'mkdir', savepath} + local unix_args = { 'mkdir', savepath } + local args = settings.system == 'windows' and windows_args or unix_args + local res = utils.subprocess({ args = args, cancellable = false }) + if res.status ~= 0 then + msg.error("Failed to create playlist save directory "..savepath..". Error: "..(res.error or "unknown")) + return + end + end + + local date = os.date("*t") + local datestring = ("%02d-%02d-%02d_%02d-%02d-%02d"):format(date.year, date.month, date.day, date.hour, date.min, date.sec) + + local name = filename or datestring.."_playlist-size_"..length..".m3u" + + local savepath = utils.join_path(savepath, name) + local file, err = io.open(savepath, "w") + if not file then + msg.error("Error in creating playlist file, check permissions. Error: "..(err or "unknown")) + else + local i=0 + while i < length do + local pwd = mp.get_property("working-directory") + local filename = mp.get_property('playlist/'..i..'/filename') + local fullpath = filename + if not filename:match("^%a%a+:%/%/") then + fullpath = utils.join_path(pwd, filename) + end + local title = mp.get_property('playlist/'..i..'/title') or url_table[filename] + if title then + file:write("#EXTINF:,"..title.."\n") + end + file:write(fullpath, "\n") + i=i+1 + end + local saved_msg = "Playlist written to: "..savepath + if settings.display_osd_feedback then mp.osd_message(saved_msg) end + msg.info(saved_msg) + file:close() + end +end + +function alphanumsort(a, b) + local function padnum(d) + local dec, n = string.match(d, "(%.?)0*(.+)") + return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n) + end + return tostring(a):lower():gsub("%.?%d+",padnum)..("%3d"):format(#b) + < tostring(b):lower():gsub("%.?%d+",padnum)..("%3d"):format(#a) +end + +function dosort(a,b) + if settings.alphanumsort then + return alphanumsort(a,b) + else + return a < b + end +end + +-- fast sort algo from https://github.com/zsugabubus/dotfiles/blob/master/.config/mpv/scripts/playlist-filtersort.lua +function sortplaylist(startover) + local playlist = mp.get_property_native('playlist') + if #playlist < 2 then return end + + local order = {} + for i=1, #playlist do + order[i] = i + playlist[i].string = get_name_from_index(i - 1, true) + end + + table.sort(order, function(a, b) + return dosort(playlist[a].string, playlist[b].string) + end) + + for i=1, #playlist do + playlist[order[i]].new_pos = i + end + + for i=1, #playlist do + while true do + local j = playlist[i].new_pos + if i == j then + break + end + mp.commandv('playlist-move', (i) - 1, (j + 1) - 1) + mp.commandv('playlist-move', (j - 1) - 1, (i) - 1) + playlist[j], playlist[i] = playlist[i], playlist[j] + end + end + cursor = mp.get_property_number('playlist-pos', 0) + if startover then + mp.set_property('playlist-pos', 0) + end + if playlist_visible then showplaylist() end +end + +function autosort(name, param) + if param == 0 then return end + if plen < param then + msg.info("Playlistmanager autosorting playlist") + refresh_globals() + sortplaylist() + end +end + +function reverseplaylist() + local length = mp.get_property_number('playlist-count', 0) + if length < 2 then return end + for outer=1, length-1, 1 do + mp.commandv('playlist-move', outer, 0) + end + if playlist_visible then + showplaylist() + elseif settings.display_osd_feedback then + mp.osd_message("Playlist reversed") + end +end + +function shuffleplaylist() + refresh_globals() + if plen < 2 then return end + mp.command("playlist-shuffle") + math.randomseed(os.time()) + mp.commandv("playlist-move", pos, math.random(0, plen-1)) + mp.set_property('playlist-pos', 0) + refresh_globals() + if playlist_visible then + showplaylist() + elseif settings.display_osd_feedback then + mp.osd_message("Playlist shuffled") + end +end + +function bind_keys(keys, name, func, opts) + if not keys then + mp.add_forced_key_binding(keys, name, func, opts) + return + end + local i = 1 + for key in keys:gmatch("[^%s]+") do + local prefix = i == 1 and '' or i + mp.add_forced_key_binding(key, name..prefix, func, opts) + i = i + 1 + end +end + +function unbind_keys(keys, name) + if not keys then + mp.remove_key_binding(name) + return + end + local i = 1 + for key in keys:gmatch("[^%s]+") do + local prefix = i == 1 and '' or i + mp.remove_key_binding(name..prefix) + i = i + 1 + end +end + +function add_keybinds() + bind_keys(settings.key_moveup, 'moveup', moveup, "repeatable") + bind_keys(settings.key_movedown, 'movedown', movedown, "repeatable") + bind_keys(settings.key_movepageup, 'movepageup', movepageup, "repeatable") + bind_keys(settings.key_movepagedown, 'movepagedown', movepagedown, "repeatable") + bind_keys(settings.key_movebegin, 'movebegin', movebegin, "repeatable") + bind_keys(settings.key_moveend, 'moveend', moveend, "repeatable") + bind_keys(settings.key_selectfile, 'selectfile', selectfile) + bind_keys(settings.key_unselectfile, 'unselectfile', unselectfile) + bind_keys(settings.key_playfile, 'playfile', playfile) + bind_keys(settings.key_removefile, 'removefile', removefile, "repeatable") + bind_keys(settings.key_closeplaylist, 'closeplaylist', remove_keybinds) +end + +function remove_keybinds() + keybindstimer:kill() + keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) + keybindstimer:kill() + mp.set_osd_ass(0, 0, "") + playlist_visible = false + if settings.reset_cursor_on_close then + resetcursor() + end + if settings.dynamic_binds then + unbind_keys(settings.key_moveup, 'moveup') + unbind_keys(settings.key_movedown, 'movedown') + unbind_keys(settings.key_movepageup, 'movepageup') + unbind_keys(settings.key_movepagedown, 'movepagedown') + unbind_keys(settings.key_movebegin, 'movebegin') + unbind_keys(settings.key_moveend, 'moveend') + unbind_keys(settings.key_selectfile, 'selectfile') + unbind_keys(settings.key_unselectfile, 'unselectfile') + unbind_keys(settings.key_playfile, 'playfile') + unbind_keys(settings.key_removefile, 'removefile') + unbind_keys(settings.key_closeplaylist, 'closeplaylist') + end +end + +keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) +keybindstimer:kill() + +if not settings.dynamic_binds then + add_keybinds() +end + +if settings.loadfiles_on_idle_start and mp.get_property_number('playlist-count', 0) == 0 then + playlist() +end + +promised_sort_watch = false +if settings.sortplaylist_on_file_add then + promised_sort_watch = true +end + +promised_sort = false +if settings.sortplaylist_on_start then + promised_sort = true +end + +mp.observe_property('playlist-count', "number", function() + if playlist_visible then showplaylist() end + if settings.prefer_titles == 'none' then return end + -- resolve titles + resolve_titles() +end) + +--resolves url titles by calling youtube-dl +function resolve_titles() + if not settings.resolve_titles then return end + local length = mp.get_property_number('playlist-count', 0) + if length < 2 then return end + local i=0 + -- loop all items in playlist because we can't predict how it has changed + while i < length do + local filename = mp.get_property('playlist/'..i..'/filename') + local title = mp.get_property('playlist/'..i..'/title') + if i ~= pos + and filename + and filename:match('^https?://') + and not title + and not url_table[filename] + and not requested_urls[filename] + then + requested_urls[filename] = true + + local args = { settings.youtube_dl_executable, '--no-playlist', '--flat-playlist', '-sJ', filename } + local req = mp.command_native_async( + { + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true + }, function (success, res) + if res.killed_by_us then + msg.verbose('Request to resolve url title ' .. filename .. ' timed out') + return + end + if res.status == 0 then + local json, err = utils.parse_json(res.stdout) + if not err then + local is_playlist = json['_type'] and json['_type'] == 'playlist' + local title = (is_playlist and '[playlist]: ' or '') .. json['title'] + msg.verbose(filename .. " resolved to '" .. title .. "'") + url_table[filename] = title + refresh_globals() + if playlist_visible then showplaylist() end + return + else + msg.error("Failed parsing json, reason: "..(err or "unknown")) + end + else + msg.error("Failed to resolve url title "..filename.." Error: "..(res.error or "unknown")) + end + end) + + mp.add_timeout(settings.resolve_title_timeout, function() + mp.abort_async_command(req) + end) + + end + i=i+1 + end +end + +--script message handler +function handlemessage(msg, value, value2) + if msg == "show" and value == "playlist" then + if value2 ~= "toggle" then + showplaylist(value2) + return + else + toggle_playlist() + return + end + end + if msg == "show" and value == "filename" and strippedname and value2 then + mp.commandv('show-text', strippedname, tonumber(value2)*1000 ) ; return + end + if msg == "show" and value == "filename" and strippedname then + mp.commandv('show-text', strippedname ) ; return + end + if msg == "sort" then sortplaylist(value) ; return end + if msg == "shuffle" then shuffleplaylist() ; return end + if msg == "reverse" then reverseplaylist() ; return end + if msg == "loadfiles" then playlist(value) ; return end + if msg == "save" then save_playlist(value) ; return end + if msg == "playlist-next" then playlist_next(true) ; return end + if msg == "playlist-prev" then playlist_prev(true) ; return end + if msg == "enable-interactive-save" then interactive_save = true end +end + +mp.register_script_message("playlistmanager", handlemessage) + +mp.register_event("file-loaded", on_loaded) +mp.register_event("end-file", on_closed) + +mp.add_key_binding(settings.key_loadfiles, "loadfiles", playlist) +mp.add_key_binding(settings.key_sortplaylist, "sortplaylist", sortplaylist) +mp.add_key_binding(settings.key_saveplaylist, "saveplaylist", activate_playlist_save) +mp.add_key_binding(settings.key_showplaylist, "showplaylist", toggle_playlist) +mp.add_key_binding(settings.key_shuffleplaylist, "shuffleplaylist", shuffleplaylist) +mp.add_key_binding(settings.key_reverseplaylist, "reverseplaylist", reverseplaylist)