diff options
Diffstat (limited to 'mac/.config/mpv')
93 files changed, 36396 insertions, 0 deletions
diff --git a/mac/.config/mpv/input.conf b/mac/.config/mpv/input.conf new file mode 100644 index 0000000..de78dcc --- /dev/null +++ b/mac/.config/mpv/input.conf @@ -0,0 +1,123 @@ +# MBTN_LEFT script-binding visibility # Toggle progress bar +MBTN_LEFT_DBL cycle mute # Cycle mute +MBTN_MID cycle fullscreen # Cycle fullscreen +MBTN_RIGHT cycle pause # Toggle pause/playback mode +MBTN_BACK playlist-prev # Skip to the previous file +MBTN_FORWARD playlist-next # Skip to the next file +enter cycle fullscreen # Cycle fullscreen +right no-osd seek 1 exact; script-message-to misc show-position # Seek exactly 1 second forward +shift+right no-osd seek 30 exact; script-message-to misc show-position # Seek exactly 30 second forward +d no-osd seek 1 exact; script-message-to misc show-position # Seek exactly 1 second forward +ctrl+d no-osd seek 180 exact; script-message-to misc show-position # Seek exactly 180 second backward +shift+d no-osd seek 30 exact; script-message-to misc show-position # Seek exactly 30 second backward +alt+ctrl+d script-message-to delete_current_file delete-file # Delete file without confirmation +alt+shift+d script-message-to delete_current_file delete-file y "press 'y' to delete file" # Delete file +left no-osd seek -1 exact; script-message-to misc show-position # Seek exactly 1 second backward +shift+left no-osd seek -30 exact; script-message-to misc show-position # Seek exactly 30 second backward +a no-osd seek -1 exact; script-message-to misc show-position # Seek exactly 1 second backward +ctrl+a no-osd seek -180 exact; script-message-to misc show-position # Seek exactly 180 second backward +shift+a no-osd seek -30 exact; script-message-to misc show-position # Seek exactly 30 second backward +v script-binding showplaylist # Playlist in current path +shift+v script-binding navigator # Playlist in all path +alt+v vf toggle vflip # Flip vertically +c no-osd seek 15 exact; script-message-to misc show-position # Seek exactly 5 seconds forward +ctrl+c no-osd seek 300 exact; script-message-to misc show-position # Seek exactly 300 second backward +shift+c no-osd seek 60 exact; script-message-to misc show-position # Seek exactly 5 seconds forward +alt+c add video-zoom 0.1 # Zoom in +z no-osd seek -15 exact; script-message-to misc show-position # Seek exactly 5 seconds backward +ctrl+z no-osd seek -300 exact; script-message-to misc show-position # Seek exactly 300 second backward +shift+z no-osd seek -60 exact; script-message-to misc show-position # Seek exactly 5 seconds backward +alt+z add video-zoom -0.1 # Zoom out +q no-osd sub-seek -1 # Seek to the previous subtitle +ctrl+q add chapter -1 # Seek -chapters +shift+q cycle sub down # Switch subtitle track backwards +alt+q add audio-delay -0.100 # change audio/video sync by shifting the audio earlier +alt+shift+q add audio-delay -1.000 # change audio/video sync by shifting the audio earlier +e no-osd sub-seek 1 # Seek to the next subtitle +ctrl+e add chapter 1 # Seek +chapters +shift+e cycle sub # Switch subtitle track +alt+e add audio-delay 0.100 # change audio/video sync by delaying the audio +alt+shift+e add audio-delay 1.000 # change audio/video sync by delaying the audio +space cycle pause # Toggle pause/playback mode +s cycle pause # Toggle pause/playback mode +ctrl+PRINT script-binding crop-screenshot # Crop screenshot +ctrl+l script-message-to misc cycle-known-tracks audio # Loop auidio +shift+l no-osd seek 30 exact; script-message-to misc show-position # Seek exactly 1 second forward +shift+h no-osd seek -30 exact; script-message-to misc show-position # Seek exactly 1 second backward +alt+h vf toggle hflip # Flip horizontally +shift+k no-osd seek 300 exact; script-message-to misc show-position # Seek exactly 5 seconds forward +shift+j no-osd seek -300 exact; script-message-to misc show-position # Seek exactly 5 seconds backward +x playlist-next # Skip to the next file +> playlist-next # Skip to the next file +w playlist-prev # Skip to the previous file +< playlist-prev # Skip to the previous file +WHEEL_DOWN add volume -5 # Volume 5 down +down add volume -5 # Volume 5 down +f add volume -5 # Volume 5 down +WHEEL_UP add volume 5 # Volume 5 up +up add volume 5 # Volume 5 up +r add volume 5 # Volume 5 up +ctrl+r script-binding reload/reload # Reload +shift+r script-binding rename-file # Rename file +alt+r script-binding history/history-resume # Resume history +alt+shift+r script-binding history/play-recent # Play recent in history +b cycle fullscreen # Toggle fullscreen +shift+b script-message-to misc quick-bookmark # Bookmarks +ctrl+t script-binding generate-thumbnails # Thumbnails +alt+h script-message cycle_video_rotate -90 # Rotate -90 +alt+l script-message cycle_video_rotate 90 # Rotate 90 +g script-message contact-sheet-close; script-message playlist-view-toggle # Toggle gallery +i script-message-to misc print-media-info # Toggle video info +n set volume 50 # Set volume 50 +shift+n set speed 1.0 # Set normal speed 1.0 +m cycle mute # Toggle mute +shift+m set speed 1.2 # Set default speed 1.2 +ctrl+m set volume 100 # Set volume 100 +shift+, multiply speed 1/1.1 # Speed down around .1 +shift+. multiply speed 1.1 # Speed up around .1 +F1 script-message-to command_palette show-command-palette "Command Palette" # Command Palette +F2 script-message-to command_palette show-command-palette "Bindings" # Show bindings +shift+F2 script-binding mdmenu/bindings # Binding list +F3 script-message-to command_palette show-command-palette "Commands" # Show commands +F4 script-message-to command_palette show-command-palette "Properties" # Show properties +F5 script-message-to command_palette show-command-palette "Options" # Show options +F6 script-message-to command_palette show-command-palette "Chapters" # Show chapters +shift+F6 script-binding mdmenu/chapters # Chapter list +F7 script-message-to command_palette show-command-palette "Playlist" # Show playlist +shift+F7 script-binding mdmenu/playlist # Playlist +F8 script-message-to command_palette show-command-palette "Video Tracks" # Show video tracks +shift+F8 script-binding mdmenu/tracklist # Track list +F9 script-message-to command_palette show-command-palette "Subtitle Tracks" # Show subtitle tracks +F10 script-message-to command_palette show-command-palette "Profiles" # Show profiles +F11 script-message-to command_palette show-command-palette "Audio Tracks" # Show audio tracks +F12 script-message-to command_palette show-command-palette "Audio Devices" # Show audio devices +ctrl+f script-message-to subtitle_search start-search # Search subtitle +ctrl+shift+f script-message-to subtitle_search show-all-lines # Search all lines of subtitle +shift+s script-message-to misc cycle-known-tracks audio # Cycle audio tracks +ctrl+s script-message-to misc cycle-known-tracks sub up # Cycle subtitle track up +ctrl+shift+s script-message-to misc cycle-known-tracks sub down # Cycle subtitle track down +/ script-binding yt # Search yt +? script-binding youtube-search # Search Youtube video +TAB script-binding visibility # Toggle progress bar +1 seek 10 absolute-percent # Seek 10% of a video +2 seek 20 absolute-percent # Seek 20% of a video +3 seek 30 absolute-percent # Seek 30% of a video +4 seek 40 absolute-percent # Seek 40% of a video +5 seek 50 absolute-percent # Seek 50% of a video +6 seek 60 absolute-percent # Seek 60% of a video +7 seek 70 absolute-percent # Seek 70% of a video +8 seek 80 absolute-percent # Seek 80% of a video +9 seek 90 absolute-percent # Seek 90% of a video +( seek 99 absolute-percent # Seek 99% of a video +0 seek 0 absolute-percent # Seek 0% of a video +) seek 99.9 absolute-percent # Seek 99.9% of a video +alt+1 set current-window-scale 0.5 # Halve the window size +alt+2 set current-window-scale 1.0 # Reset the window size +alt+3 set current-window-scale 2.0 # Double the window size +- add video-zoom -0.1 # Zoom out += set video-zoom 0 # Zoom reset ++ add video-zoom 0.1 # Zoom in +[ script-binding UndoRedo/undo # Jump to previous position +{ script-binding UndoRedo/undoCaps # Jump to previous position +] script-binding UndoRedo/redo # Jump to undo position +} script-binding UndoRedo/redoCaps # Jump to undo position diff --git a/mac/.config/mpv/mpv.conf b/mac/.config/mpv/mpv.conf new file mode 100644 index 0000000..c38da51 --- /dev/null +++ b/mac/.config/mpv/mpv.conf @@ -0,0 +1,11 @@ +--script-opts=osc-scalewindowed=0.75,osc-scalefullscreen=0.75,osc-boxalpha=200,osc-visibility=always,osc-seekbarstyle=knob,osc-seekbarhandlesize=0.3,osc-seekbarkeyframes=no +quiet=yes +screenshot-directory="~/Pictures/screenshots" +screenshot-template="%F-%P" +sub-file-paths=Media/** +audio-file-paths=Music/** +--no-input-builtin-bindings +--loop-playlist=inf +# --speed=1.2 +--volume=0 +sub-font-provider=fontconfig diff --git a/mac/.config/mpv/osc.conf b/mac/.config/mpv/osc.conf new file mode 100644 index 0000000..68f6a4e --- /dev/null +++ b/mac/.config/mpv/osc.conf @@ -0,0 +1 @@ +--script-opts=visibility=always diff --git a/mac/.config/mpv/script-modules/extended-menu.lua b/mac/.config/mpv/script-modules/extended-menu.lua new file mode 100644 index 0000000..b663e93 --- /dev/null +++ b/mac/.config/mpv/script-modules/extended-menu.lua @@ -0,0 +1,1214 @@ +local mp = require("mp") +local utils = require("mp.utils") +local assdraw = require("mp.assdraw") + +-- create namespace with default values +local em = { + + -- customisable values ------------------------------------------------------ + + loop_when_navigating = false, -- Loop when navigating through list + lines_to_show = 17, -- NOT including search line + pause_on_open = true, + resume_on_exit = "only-if-was-paused", -- another possible value is true + + -- styles (earlyer it was a table, but required many more steps to pass def-s + -- here from .conf file) + font_size = 21, + --font size scales by window + scale_by_window = false, + -- cursor 'width', useful to change if you have hidpi monitor + cursor_x_border = 0.3, + line_bottom_margin = 1, -- basically space between lines + text_color = { + default = "ffffff", + accent = "d8a07b", + current = "aaaaaa", + comment = "636363", + }, + menu_x_padding = 5, -- this padding for now applies only to 'left', not x + menu_y_padding = 2, -- but this one applies to both - top & bottom + + -- values that should be passed from main script ---------------------------- + + search_heading = "Default search heading", + -- 'full' is required from main script, 'current_i' is optional + -- others are 'private' + list = { + full = {}, + filtered = {}, + current_i = nil, + pointer_i = 1, + show_from_to = {}, + }, + -- field to compare with when searching for 'current value' by 'current_i' + index_field = "index", + -- fields to use when searching for string match / any other custom searching + -- if value has 0 length, then search list item itself + filter_by_fields = {}, + + -- 'private' values that are not supposed to be changed from the outside ---- + + is_active = false, + -- https://mpv.io/manual/master/#lua-scripting-mp-create-osd-overlay(format) + ass = mp.create_osd_overlay("ass-events"), + was_paused = false, -- flag that indicates that vid was paused by this script + + line = "", + -- if there was no cursor it wouldn't have been needed, but for now we need + -- variable below only to compare it with 'line' and see if we need to filter + prev_line = "", + cursor = 1, + history = {}, + history_pos = 1, + key_bindings = {}, + insert_mode = false, + + -- used only in 'update' func to get error text msgs + error_codes = { + no_match = "Match required", + no_submit_provided = "No submit function provided", + }, +} + +-- PRIVATE METHODS ------------------------------------------------------------ + +-- declare constructor function +function em:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + + -- some options might be customised by user in .conf file and read as strings + -- in that case parse those + if type(o.filter_by_fields) == "string" then + o.filter_by_fields = utils.parse_json(o.filter_by_fields) + end + + if type(o.text_color) == "string" then + o.text_color = utils.parse_json(o.text_color) + end + + return o +end + +-- this func is just a getter of a current list depending on search line +function em:current() + return self.line == "" and self.list.full or self.list.filtered +end + +-- REVIEW: how to get rid of this wrapper and handle filter func sideeffects +-- in a more elegant way? +function em:filter_wrapper() + -- handles sideeffect that are needed to be run on filtering list + -- cuz the filter func may be redefined in main script and therefore needs + -- to be straight forward - only doing filtering and returning the table + + -- passing current query just in case, so ppl can use it in their custom funcs + self.list.filtered = self:filter(self.line) + + self.prev_line = self.line + self.list.pointer_i = 1 + self:set_from_to(true) +end + +function em:set_from_to(reset_flag) + -- additional variables just for shorter var name + local i = self.list.pointer_i + local to_show = self.lines_to_show + local total = #self:current() + + if reset_flag or to_show >= total then + self.list.show_from_to = { 1, math.min(to_show, total) } + return + end + + -- If menu is opened with something already selected we want this 'selected' + -- to be displayed close to the middle of the menu. That's why 'show_from_to' + -- is not initially set, so we can know - if show_from_to length is 0 - it is + -- first call of this func in cur. init + if #self.list.show_from_to == 0 then + -- set show_from_to so chosen item will be displayed close to middle + local half_list = math.ceil(to_show / 2) + if i < half_list then + self.list.show_from_to = { 1, to_show } + elseif total - i < half_list then + self.list.show_from_to = { total - to_show + 1, total } + else + self.list.show_from_to = { i - half_list + 1, i - half_list + to_show } + end + else + table.unpack = table.unpack or unpack -- 5.1 compatibility + local first, last = table.unpack(self.list.show_from_to) + + -- handle cursor moving towards start / end bondary + if first ~= 1 and i - first < 2 then + self.list.show_from_to = { first - 1, last - 1 } + end + if last ~= total and last - i < 2 then + self.list.show_from_to = { first + 1, last + 1 } + end + + -- handle index jumps from beginning to end and backwards + if i > last then + self.list.show_from_to = { i - to_show + 1, i } + end + if i < first then + self.list.show_from_to = { 1, to_show } + end + end +end + +function em:change_selected_index(num) + self.list.pointer_i = self.list.pointer_i + num + if self.loop_when_navigating then + if self.list.pointer_i < 1 then + self.list.pointer_i = #self:current() + elseif self.list.pointer_i > #self:current() then + self.list.pointer_i = 1 + end + else + if self.list.pointer_i < 1 then + self.list.pointer_i = 1 + elseif self.list.pointer_i > #self:current() then + self.list.pointer_i = #self:current() + end + end + self:set_from_to() + self:update() +end + +-- Render the REPL and console as an ASS OSD +function em:update(err_code) + -- ASS tags documentation here - https://aegi.vmoe.info/docs/3.0/ASS_Tags/ + + -- do not bother if function was called to close the menu.. + if not self.is_active then + em.ass:remove() + return + end + + local line_height = self.font_size + self.line_bottom_margin + local _, h, aspect = mp.get_osd_size() + local wh = self.scale_by_window and 720 or h + local ww = wh * aspect + + -- '+ 1' below is a search string + local menu_y_pos = wh - (line_height * (self.lines_to_show + 1) + self.menu_y_padding * 2) + + -- didn't find better place to handle filtered list update + if self.line ~= self.prev_line then + self:filter_wrapper() + end + + local function get_background() + local a = self:ass_new_wrapper() + a:append("{\\1c&H1c1c1c\\1a&H19}") -- background color & opacity + a:pos(0, 0) + a:draw_start() + a:rect_cw(0, menu_y_pos, ww, wh) + a:draw_stop() + return a.text + end + + local function get_search_header() + local a = self:ass_new_wrapper() + + a:pos(self.menu_x_padding, menu_y_pos + self.menu_y_padding) + + local search_prefix = table.concat({ + self:get_font_color("accent"), + (#self:current() ~= 0 and self.list.pointer_i or "!"), + "/", + #self:current(), + "\\h\\h", + self.search_heading, + ":\\h", + }) + + a:append(search_prefix) + -- reset font color after search prefix + a:append(self:get_font_color("default")) + + -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor + -- inline with the surrounding text, but it sets the advance to the width + -- of the drawing. So the cursor doesn't affect layout too much, make it as + -- thin as possible and make it appear to be 1px wide by giving it 0.5px + -- horizontal borders. + local cheight = self.font_size * 8 + -- TODO: maybe do it using draw_rect from ass? + local cglyph = "{\\r" -- styles reset + .. "\\1c&Hffffff&\\3c&Hffffff" -- font color and border color + .. "\\xbord" + .. self.cursor_x_border + .. "\\p4\\pbo24}" -- xborder, scale x8 and baseline offset + .. "m 0 0 l 0 " + .. cheight -- drawing just a line + .. "{\\p0\\r}" -- finish drawing and reset styles + local before_cur = self:ass_escape(self.line:sub(1, self.cursor - 1)) + local after_cur = self:ass_escape(self.line:sub(self.cursor)) + + a:append(table.concat({ + before_cur, + cglyph, + self:reset_styles(), + self:get_font_color("default"), + after_cur, + (err_code and "\\h" .. self.error_codes[err_code] or ""), + })) + + return a.text + + -- NOTE: perhaps this commented code will some day help me in coding cursor + -- like in M-x emacs menu: + -- Redraw the cursor with the REPL text invisible. This will make the + -- cursor appear in front of the text. + -- ass:new_event() + -- ass:an(1) + -- ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur) + -- ass:append(cglyph) + -- ass:append(style .. '{\\alpha&HFF&}' .. after_cur) + end + + local function get_list() + local a = assdraw.ass_new() + + local function apply_highlighting(y) + a:new_event() + a:append(self:reset_styles()) + a:append("{\\1c&Hffffff\\1a&HE6}") -- background color & opacity + a:pos(0, 0) + a:draw_start() + a:rect_cw(0, y, ww, y + self.font_size) + a:draw_stop() + end + + -- REVIEW: maybe make another function 'get_line_str' and move there + -- everything from this for loop? + -- REVIEW: how to use something like table.unpack below? + for i = self.list.show_from_to[1], self.list.show_from_to[2] do + local value = assert(self:current()[i], "no value with index " .. i) + local y_offset = menu_y_pos + self.menu_y_padding + (line_height * (i - self.list.show_from_to[1] + 1)) + + if i == self.list.pointer_i then + apply_highlighting(y_offset) + end + + a:new_event() + a:append(self:reset_styles()) + a:pos(self.menu_x_padding, y_offset) + a:append(self:get_line(i, value)) + end + + return a.text + end + + em.ass.res_x = ww + em.ass.res_y = wh + em.ass.data = table.concat({ + get_background(), + get_search_header(), + get_list(), + }, "\n") + + em.ass:update() +end + +-- params: +-- - data : {list: {}, [current_i] : num} +function em:init(data) + self.list.full = data.list or {} + self.list.current_i = data.current_i or nil + self.list.pointer_i = data.current_i or 1 + self:set_active(true) +end + +function em:exit() + self:undefine_key_bindings() + collectgarbage() +end + +-- TODO: write some idle func like this +-- function idle() +-- if pending_selection then +-- gallery:set_selection(pending_selection) +-- pending_selection = nil +-- end +-- if ass_changed or geometry_changed then +-- local ww, wh = mp.get_osd_size() +-- if geometry_changed then +-- geometry_changed = false +-- compute_geometry(ww, wh) +-- end +-- if ass_changed then +-- ass_changed = false +-- mp.set_osd_ass(ww, wh, ass) +-- end +-- end +-- end +-- ... +-- and handle it as follows +-- init(): +-- mp.register_idle(idle) +-- idle() +-- exit(): +-- mp.unregister_idle(idle) +-- idle() +-- And in these observers he is setting a flag, that's being checked in func above +-- mp.observe_property("osd-width", "native", mark_geometry_stale) +-- mp.observe_property("osd-height", "native", mark_geometry_stale) + +-- PRIVATE METHODS END -------------------------------------------------------- + +-- PUBLIC METHODS ------------------------------------------------------------- + +function em:filter() + -- default filter func, might be redefined in main script + local result = {} + + local function get_full_search_str(v) + local str = "" + for _, key in ipairs(self.filter_by_fields) do + str = str .. (v[key] or "") + end + return str + end + + for _, v in ipairs(self.list.full) do + -- if filter_by_fields has 0 length, then search list item itself + if #self.filter_by_fields == 0 then + if self:search_method(v) then + table.insert(result, v) + end + else + -- NOTE: we might use search_method on fiels separately like this: + -- for _,key in ipairs(self.filter_by_fields) do + -- if self:search_method(v[key]) then table.insert(result, v) end + -- end + -- But since im planning to implement fuzzy search in future i need full + -- search string here + if self:search_method(get_full_search_str(v)) then + table.insert(result, v) + end + end + end + return result +end + +-- TODO: implement fuzzy search and maybe match highlights +function em:search_method(str) + -- also might be redefined by main script + + -- convert to string just to make sure.. + return tostring(str):lower():find(self.line:lower(), 1, true) +end + +-- this module requires submit function to be defined in main script +function em:submit() + self:update("no_submit_provided") +end + +function em:update_list(list) + -- for now this func doesn't handle cases when we have 'current_i' to update + -- it + self.list.full = list + if self.line ~= self.prev_line then + self:filter_wrapper() + end +end + +-- PUBLIC METHODS END --------------------------------------------------------- + +-- HELPER METHODS ------------------------------------------------------------- + +function em:get_line(_, v) -- [i]ndex, [v]alue + -- this func might be redefined in main script to get a custom-formatted line + -- default implementation of this func supposes that value.content field is a + -- String + local a = assdraw.ass_new() + local style = (self.list.current_i == v[self.index_field]) and "current" or "default" + + a:append(self:reset_styles()) + a:append(self:get_font_color(style)) + -- content as default field, which is holding string + -- no point in moving it to main object since content itself is being + -- composed in THIS function, that might (and most likely, should) be + -- redefined in main script + a:append(v.content or "Something is off in `get_line` func") + return a.text +end + +-- REVIEW: for now i don't see normal way of mergin this func with below one +-- but it's being used only once +function em:reset_styles() + local a = assdraw.ass_new() + -- alignment top left, no word wrapping, border 0, shadow 0 + a:append("{\\an7\\q2\\bord0\\shad0}") + a:append("{\\fs" .. self.font_size .. "}") + return a.text +end + +-- function to get rid of some copypaste +function em:ass_new_wrapper() + local a = assdraw.ass_new() + a:new_event() + a:append(self:reset_styles()) + return a +end + +function em:get_font_color(style) + return "{\\1c&H" .. self.text_color[style] .. "}" +end + +-- HELPER METHODS END --------------------------------------------------------- + +--[[ + The below code is a modified implementation of text input from mpv's console.lua: + https://github.com/mpv-player/mpv/blob/87c9eefb2928252497f6141e847b74ad1158bc61/player/lua/console.lua + + I was too lazy to list all modifications i've done to the script, but if u + rly need to see those - do diff with the original code +]] +-- + +------------------------------------------------------------------------------- +-- START ORIGINAL MPV CODE -- +------------------------------------------------------------------------------- + +-- Copyright (C) 2019 the mpv developers +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +function em:detect_platform() + local o = {} + -- Kind of a dumb way of detecting the platform but whatever + if mp.get_property_native("options/vo-mmcss-profile", o) ~= o then + return "windows" + elseif mp.get_property_native("options/macos-force-dedicated-gpu", o) ~= o then + return "macos" + elseif os.getenv("WAYLAND_DISPLAY") then + return "wayland" + end + return "x11" +end + +-- Escape a string for verbatim display on the OSD +function em:ass_escape(str) + -- 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 + str = str:gsub("\\", "\\\239\187\191") + str = str:gsub("{", "\\{") + str = str:gsub("}", "\\}") + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub("\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") + return str +end + +-- Set the REPL visibility ("enable", Esc) +function em:set_active(active) + if active == self.is_active then + return + end + if active then + self.is_active = true + self.insert_mode = false + mp.enable_messages("terminal-default") + self:define_key_bindings() + + -- set flag 'was_paused' only if vid wasn't paused before EM init + if self.pause_on_open and not mp.get_property_bool("pause", false) then + mp.set_property_bool("pause", true) + self.was_paused = true + end + + self:set_from_to() + self:update() + else + -- no need to call 'update' in this block cuz 'clear' method is calling it + self.is_active = false + self:undefine_key_bindings() + + if self.resume_on_exit == true or (self.resume_on_exit == "only-if-was-paused" and self.was_paused) then + mp.set_property_bool("pause", false) + end + + self:clear() + collectgarbage() + end +end + +-- Naive helper function to find the next UTF-8 character in 'str' after 'pos' +-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. +function em:next_utf8(str, pos) + if pos > str:len() then + return pos + end + repeat + pos = pos + 1 + until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' +function em:prev_utf8(str, pos) + if pos <= 1 then + return pos + end + repeat + pos = pos - 1 + until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- Insert a character at the current cursor position (any_unicode) +function em:handle_char_input(c) + if self.insert_mode then + self.line = self.line:sub(1, self.cursor - 1) .. c .. self.line:sub(self:next_utf8(self.line, self.cursor)) + else + self.line = self.line:sub(1, self.cursor - 1) .. c .. self.line:sub(self.cursor) + end + self.cursor = self.cursor + #c + self:update() +end + +-- Remove the character behind the cursor (Backspace) +function em:handle_backspace() + if self.cursor <= 1 then + return + end + local prev = self:prev_utf8(self.line, self.cursor) + self.line = self.line:sub(1, prev - 1) .. self.line:sub(self.cursor) + self.cursor = prev + self:update() +end + +-- Remove the character in front of the cursor (Del) +function em:handle_del() + if self.cursor > self.line:len() then + return + end + self.line = self.line:sub(1, self.cursor - 1) .. self.line:sub(self:next_utf8(self.line, self.cursor)) + self:update() +end + +-- Toggle insert mode (Ins) +function em:handle_ins() + self.insert_mode = not self.insert_mode +end + +-- Move the cursor to the next character (Right) +function em:next_char() + self.cursor = self:next_utf8(self.line, self.cursor) + self:update() +end + +-- Move the cursor to the previous character (Left) +function em:prev_char() + self.cursor = self:prev_utf8(self.line, self.cursor) + self:update() +end + +-- Clear the current line (Ctrl+C) +function em:clear() + self.line = "" + self.prev_line = "" + + self.list.current_i = nil + self.list.pointer_i = 1 + self.list.filtered = {} + self.list.show_from_to = {} + + self.was_paused = false + + self.cursor = 1 + self.insert_mode = false + self.history_pos = #self.history + 1 + + self:update() +end + +-- Run the current command and clear the line (Enter) +function em:handle_enter() + if #self:current() == 0 then + self:update("no_match") + return + end + + if self.history[#self.history] ~= self.line then + self.history[#self.history + 1] = self.line + end + + self:submit(self:current()[self.list.pointer_i]) + self:set_active(false) +end + +-- Go to the specified position in the command history +function em:go_history(new_pos) + local old_pos = self.history_pos + self.history_pos = new_pos + + -- Restrict the position to a legal value + if self.history_pos > #self.history + 1 then + self.history_pos = #self.history + 1 + elseif self.history_pos < 1 then + self.history_pos = 1 + end + + -- Do nothing if the history position didn't actually change + if self.history_pos == old_pos then + return + end + + -- If the user was editing a non-history line, save it as the last history + -- entry. This makes it much less frustrating to accidentally hit Up/Down + -- while editing a line. + if old_pos == #self.history + 1 and self.line ~= "" and self.history[#self.history] ~= self.line then + self.history[#self.history + 1] = self.line + end + + -- Now show the history line (or a blank line for #history + 1) + if self.history_pos <= #self.history then + self.line = self.history[self.history_pos] + else + self.line = "" + end + self.cursor = self.line:len() + 1 + self.insert_mode = false + self:update() +end + +-- Go to the specified relative position in the command history (Up, Down) +function em:move_history(amount) + self:go_history(self.history_pos + amount) +end + +-- Go to the first command in the command history (PgUp) +function em:handle_pgup() + -- Determine the number of items to move up (half a page) + local half_page = math.ceil(self.lines_to_show / 2) + + -- Move the history position up by half a page + self:change_selected_index(-half_page) +end + +-- Stop browsing history and start editing a blank line (PgDown) +function em:handle_pgdown() + -- Determine the number of items to move down (half a page) + local half_page = math.ceil(self.lines_to_show / 2) + + -- Move the history position down by half a page + self:change_selected_index(half_page) +end + +-- Move to the start of the current word, or if already at the start, the start +-- of the previous word. (Ctrl+Left) +function em:prev_word() + -- This is basically the same as next_word() but backwards, so reverse the + -- string in order to do a "backwards" find. This wouldn't be as annoying + -- to do if Lua didn't insist on 1-based indexing. + self.cursor = self.line:len() + - select(2, self.line:reverse():find("%s*[^%s]*", self.line:len() - self.cursor + 2)) + + 1 + self:update() +end + +-- Move to the end of the current word, or if already at the end, the end of +-- the next word. (Ctrl+Right) +function em:next_word() + self.cursor = select(2, self.line:find("%s*[^%s]*", self.cursor)) + 1 + self:update() +end + +-- Move the cursor to the beginning of the line (HOME) +function em:go_home() + self.cursor = 1 + self:update() +end + +-- Move the cursor to the end of the line (END) +function em:go_end() + self.cursor = self.line:len() + 1 + self:update() +end + +-- Delete from the cursor to the beginning of the word (Ctrl+Backspace) +function em:del_word() + local before_cur = self.line:sub(1, self.cursor - 1) + local after_cur = self.line:sub(self.cursor) + + before_cur = before_cur:gsub("[^%s]+%s*$", "", 1) + self.line = before_cur .. after_cur + self.cursor = before_cur:len() + 1 + self:update() +end + +-- Delete from the cursor to the end of the word (Ctrl+Del) +function em:del_next_word() + if self.cursor > self.line:len() then + return + end + + local before_cur = self.line:sub(1, self.cursor - 1) + local after_cur = self.line:sub(self.cursor) + + after_cur = after_cur:gsub("^%s*[^%s]+", "", 1) + self.line = before_cur .. after_cur + self:update() +end + +-- Delete from the cursor to the end of the line (Ctrl+K) +function em:del_to_eol() + self.line = self.line:sub(1, self.cursor - 1) + self:update() +end + +-- Delete from the cursor back to the start of the line (Ctrl+U) +function em:del_to_start() + self.line = self.line:sub(self.cursor) + self.cursor = 1 + self:update() +end + +-- Returns a string of UTF-8 text from the clipboard (or the primary selection) +function em:get_clipboard(clip) + -- Pick a better default font for Windows and macOS + local platform = self:detect_platform() + + if platform == "x11" then + local res = utils.subprocess({ + args = { "xclip", "-selection", clip and "clipboard" or "primary", "-out" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "wayland" then + local res = utils.subprocess({ + args = { "wl-paste", clip and "-n" or "-np" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "windows" then + local res = utils.subprocess({ + args = { + "powershell", + "-NoProfile", + "-Command", + [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + + $clip = "" + if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + } else { + Add-Type -AssemblyName PresentationCore + $clip = [Windows.Clipboard]::GetText() + } + + $clip = $clip -Replace "`r","" + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]], + }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "macos" then + local res = utils.subprocess({ + args = { "pbpaste" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + end + return "" +end + +-- Paste text from the window-system's clipboard. 'clip' determines whether the +-- clipboard or the primary selection buffer is used (on X11 and Wayland only.) +function em:paste(clip) + local text = self:get_clipboard(clip) + local before_cur = self.line:sub(1, self.cursor - 1) + local after_cur = self.line:sub(self.cursor) + self.line = before_cur .. text .. after_cur + self.cursor = self.cursor + text:len() + self:update() +end + +-- List of input bindings. This is a weird mashup between common GUI text-input +-- bindings and readline bindings. +function em:get_bindings() + local bindings = { + { + "ctrl+[", + function() + self:set_active(false) + end, + }, + { + "ctrl+g", + function() + self:set_active(false) + end, + }, + { + "esc", + function() + self:set_active(false) + end, + }, + { + "enter", + function() + self:handle_enter() + end, + }, + { + "kp_enter", + function() + self:handle_enter() + end, + }, + { + "ctrl+m", + function() + self:handle_enter() + end, + }, + { + "bs", + function() + self:handle_backspace() + end, + }, + { + "shift+bs", + function() + self:handle_backspace() + end, + }, + { + "ctrl+h", + function() + self:handle_backspace() + end, + }, + { + "del", + function() + self:handle_del() + end, + }, + { + "shift+del", + function() + self:handle_del() + end, + }, + { + "ins", + function() + self:handle_ins() + end, + }, + { + "shift+ins", + function() + self:paste(false) + end, + }, + { + "mbtn_mid", + function() + self:paste(false) + end, + }, + { + "left", + function() + self:prev_char() + end, + }, + { + "ctrl+b", + function() + self:prev_char() + end, + }, + { + "right", + function() + self:next_char() + end, + }, + { + "ctrl+f", + function() + self:next_char() + end, + }, + { + "ctrl+k", + function() + self:change_selected_index(-1) + end, + }, + { + "ctrl+p", + function() + self:change_selected_index(-1) + end, + }, + { + "ctrl+j", + function() + self:change_selected_index(1) + end, + }, + { + "ctrl+n", + function() + self:change_selected_index(1) + end, + }, + { + "up", + function() + self:move_history(-1) + end, + }, + { + "alt+p", + function() + self:move_history(-1) + end, + }, + { + "wheel_up", + function() + self:move_history(-1) + end, + }, + { + "down", + function() + self:move_history(1) + end, + }, + { + "alt+n", + function() + self:move_history(1) + end, + }, + { + "wheel_down", + function() + self:move_history(1) + end, + }, + { "wheel_left", function() end }, + { "wheel_right", function() end }, + { + "ctrl+left", + function() + self:prev_word() + end, + }, + { + "alt+b", + function() + self:prev_word() + end, + }, + { + "ctrl+right", + function() + self:next_word() + end, + }, + { + "alt+f", + function() + self:next_word() + end, + }, + { + "ctrl+a", + function() + self:go_home() + end, + }, + { + "home", + function() + self:go_home() + end, + }, + { + "ctrl+e", + function() + self:go_end() + end, + }, + { + "end", + function() + self:go_end() + end, + }, + { + "ctrl+shift+f", + function() + self:handle_pgdown() + end, + }, + { + "ctrl+shift+b", + function() + self:handle_pgup() + end, + }, + { + "pgdwn", + function() + self:handle_pgdown() + end, + }, + { + "pgup", + function() + self:handle_pgup() + end, + }, + { + "ctrl+c", + function() + self:clear() + end, + }, + { + "ctrl+d", + function() + self:handle_del() + end, + }, + { + "ctrl+u", + function() + self:del_to_start() + end, + }, + { + "ctrl+v", + function() + self:paste(true) + end, + }, + { + "meta+v", + function() + self:paste(true) + end, + }, + { + "ctrl+bs", + function() + self:del_word() + end, + }, + { + "ctrl+w", + function() + self:del_word() + end, + }, + { + "ctrl+del", + function() + self:del_next_word() + end, + }, + { + "alt+d", + function() + self:del_next_word() + end, + }, + { + "kp_dec", + function() + self:handle_char_input(".") + end, + }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = { + "kp" .. i, + function() + self:handle_char_input("" .. i) + end, + } + end + + return bindings +end + +function em:text_input(info) + if info.key_text and (info.event == "press" or info.event == "down" or info.event == "repeat") then + self:handle_char_input(info.key_text) + end +end + +function em:define_key_bindings() + if #self.key_bindings > 0 then + return + end + for _, bind in ipairs(self:get_bindings()) do + -- Generate arbitrary name for removing the bindings later. + local name = "search_" .. (#self.key_bindings + 1) + self.key_bindings[#self.key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2], { repeatable = true }) + end + mp.add_forced_key_binding("any_unicode", "search_input", function(...) + self:text_input(...) + end, { repeatable = true, complex = true }) + self.key_bindings[#self.key_bindings + 1] = "search_input" +end + +function em:undefine_key_bindings() + for _, name in ipairs(self.key_bindings) do + mp.remove_key_binding(name) + end + self.key_bindings = {} +end + +------------------------------------------------------------------------------- +-- END ORIGINAL MPV CODE -- +------------------------------------------------------------------------------- + +return em diff --git a/mac/.config/mpv/script-modules/gallery.lua b/mac/.config/mpv/script-modules/gallery.lua new file mode 100644 index 0000000..ee0be42 --- /dev/null +++ b/mac/.config/mpv/script-modules/gallery.lua @@ -0,0 +1,581 @@ +local utils = require("mp.utils") +local msg = require("mp.msg") +local assdraw = require("mp.assdraw") + +local gallery_mt = {} +gallery_mt.__index = gallery_mt + +function gallery_new() + local gallery = setmetatable({ + -- public, can be modified by user + items = {}, + item_to_overlay_path = function(index, item) + return "" + end, + item_to_thumbnail_params = function(index, item) + return "", 0 + end, + item_to_text = function(index, item) + return "", true + end, + item_to_border = function(index, item) + return 0, "" + end, + ass_show = function(ass) end, + config = { + background_color = "333333", + background_opacity = "33", + background_roundness = 5, + scrollbar = true, + scrollbar_left_side = false, + scrollbar_min_size = 10, + overlay_range = 0, + max_thumbnails = 64, + show_placeholders = true, + always_show_placeholders = false, + placeholder_color = "222222", + text_size = 28, + align_text = true, + accurate = false, + generate_thumbnails_with_mpv = false, + }, + + -- private, can be read but should not be modified + active = false, + geometry = { + ok = false, + position = { 0, 0 }, + size = { 0, 0 }, + min_spacing = { 0, 0 }, + thumbnail_size = { 0, 0 }, + rows = 0, + columns = 0, + effective_spacing = { 0, 0 }, + }, + view = { -- 1-based indices into the "playlist" array + first = 0, -- must be equal to N*columns + last = 0, -- must be > first and <= first + rows*columns + }, + overlays = { + active = {}, -- array of <=64 strings indicating the file associated to the current overlay (false if nothing) + missing = {}, -- associative array of thumbnail path to view index it should be shown at + }, + selection = nil, + ass = { + background = "", + selection = "", + scrollbar = "", + placeholders = "", + }, + generators = {}, -- list of generator scripts + }, gallery_mt) + + for i = 1, gallery.config.max_thumbnails do + gallery.overlays.active[i] = false + end + return gallery +end + +function gallery_mt.show_overlay(gallery, index_1, thumb_path) + local g = gallery.geometry + gallery.overlays.active[index_1] = thumb_path + local index_0 = index_1 - 1 + local x, y = gallery:view_index_position(index_0) + mp.commandv( + "overlay-add", + tostring(index_0 + gallery.config.overlay_range), + tostring(math.floor(x + 0.5)), + tostring(math.floor(y + 0.5)), + thumb_path, + "0", + "bgra", + tostring(g.thumbnail_size[1]), + tostring(g.thumbnail_size[2]), + tostring(4 * g.thumbnail_size[1]) + ) + mp.osd_message(" ", 0.01) +end + +function gallery_mt.remove_overlays(gallery) + for view_index, _ in pairs(gallery.overlays.active) do + mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1) + gallery.overlays.active[view_index] = false + end + gallery.overlays.missing = {} +end + +local function file_exists(path) + local info = utils.file_info(path) + return info ~= nil and info.is_file +end + +function gallery_mt.refresh_overlays(gallery, force) + local todo = {} + local o = gallery.overlays + local g = gallery.geometry + o.missing = {} + for view_index = 1, g.rows * g.columns do + local index = gallery.view.first + view_index - 1 + local active = o.active[view_index] + if index > 0 and index <= #gallery.items then + local thumb_path = gallery.item_to_overlay_path(index, gallery.items[index]) + if not force and active == thumb_path then + -- nothing to do + elseif file_exists(thumb_path) then + gallery:show_overlay(view_index, thumb_path) + else + -- need to generate that thumbnail + o.active[view_index] = false + mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1) + o.missing[thumb_path] = view_index + todo[#todo + 1] = { index = index, output = thumb_path } + end + else + -- might happen if we're close to the end of gallery.items + if active ~= false then + o.active[view_index] = false + mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1) + end + end + end + if #gallery.generators >= 1 then + -- reverse iterate so that the first thumbnail is at the top of the stack + for i = #todo, 1, -1 do + local generator = gallery.generators[i % #gallery.generators + 1] + local t = todo[i] + local input_path, time = gallery.item_to_thumbnail_params(t.index, gallery.items[t.index]) + mp.commandv( + "script-message-to", + generator, + "push-thumbnail-front", + mp.get_script_name(), + input_path, + tostring(g.thumbnail_size[1]), + tostring(g.thumbnail_size[2]), + time, + t.output, + gallery.config.accurate and "true" or "false", + gallery.config.generate_thumbnails_with_mpv and "true" or "false" + ) + end + end +end + +function gallery_mt.index_at(gallery, mx, my) + local g = gallery.geometry + if mx < g.position[1] or my < g.position[2] then + return nil + end + mx = mx - g.position[1] + my = my - g.position[2] + if mx > g.size[1] or my > g.size[2] then + return nil + end + mx = mx - g.effective_spacing[1] + my = my - g.effective_spacing[2] + local on_column = (mx % (g.thumbnail_size[1] + g.effective_spacing[1])) < g.thumbnail_size[1] + local on_row = (my % (g.thumbnail_size[2] + g.effective_spacing[2])) < g.thumbnail_size[2] + if on_column and on_row then + local column = math.floor(mx / (g.thumbnail_size[1] + g.effective_spacing[1])) + local row = math.floor(my / (g.thumbnail_size[2] + g.effective_spacing[2])) + local index = gallery.view.first + row * g.columns + column + if index > 0 and index <= gallery.view.last then + return index + end + end + return nil +end + +function gallery_mt.compute_internal_geometry(gallery) + local g = gallery.geometry + g.rows = math.floor((g.size[2] - g.min_spacing[2]) / (g.thumbnail_size[2] + g.min_spacing[2])) + g.columns = math.floor((g.size[1] - g.min_spacing[1]) / (g.thumbnail_size[1] + g.min_spacing[1])) + if g.rows <= 0 or g.columns <= 0 then + g.rows = 0 + g.columns = 0 + g.effective_spacing[1] = g.size[1] + g.effective_spacing[2] = g.size[2] + return + end + if g.rows * g.columns > gallery.config.max_thumbnails then + local r = math.sqrt(g.rows * g.columns / gallery.config.max_thumbnails) + g.rows = math.floor(g.rows / r) + g.columns = math.floor(g.columns / r) + end + g.effective_spacing[1] = (g.size[1] - g.columns * g.thumbnail_size[1]) / (g.columns + 1) + g.effective_spacing[2] = (g.size[2] - g.rows * g.thumbnail_size[2]) / (g.rows + 1) +end + +-- makes sure that view.first and view.last are valid with regards to the playlist +-- and that selection is within the view +-- to be called after the playlist, view or selection was modified somehow +function gallery_mt.ensure_view_valid(gallery) + local g = gallery.geometry + if #gallery.items == 0 or g.rows == 0 or g.columns == 0 then + gallery.view.first = 0 + gallery.view.last = 0 + return + end + local v = gallery.view + local selection_row = math.floor((gallery.selection - 1) / g.columns) + local max_thumbs = g.rows * g.columns + local changed = false + + if v.last >= #gallery.items then + v.last = #gallery.items + if g.rows == 1 then + v.first = math.max(1, v.last - g.columns + 1) + else + local last_row = math.floor((v.last - 1) / g.columns) + local first_row = math.max(0, last_row - g.rows + 1) + v.first = 1 + first_row * g.columns + end + changed = true + elseif v.first == 0 or v.last == 0 or v.last - v.first + 1 ~= max_thumbs then + -- special case: the number of possible thumbnails was changed + -- just recreate the view such that the selection is in the middle row + local max_row = (#gallery.items - 1) / g.columns + 1 + local row_first = selection_row - math.floor((g.rows - 1) / 2) + local row_last = selection_row + math.floor((g.rows - 1) / 2) + g.rows % 2 + if row_first < 0 then + row_first = 0 + elseif row_last > max_row then + row_first = max_row - g.rows + 1 + end + v.first = 1 + row_first * g.columns + v.last = math.min(#gallery.items, v.first - 1 + max_thumbs) + return true + end + + if gallery.selection < v.first then + -- the selection is now on the first line + v.first = (g.rows == 1) and gallery.selection or selection_row * g.columns + 1 + v.last = math.min(#gallery.items, v.first + max_thumbs - 1) + changed = true + elseif gallery.selection > v.last then + v.last = (g.rows == 1) and gallery.selection or (selection_row + 1) * g.columns + v.first = math.max(1, v.last - max_thumbs + 1) + v.last = math.min(#gallery.items, v.last) + changed = true + end + return changed +end + +-- ass related stuff +function gallery_mt.refresh_background(gallery) + local g = gallery.geometry + local a = assdraw.ass_new() + a:new_event() + a:append("{\\an7}") + a:append("{\\bord0}") + a:append("{\\shad0}") + a:append("{\\1c&" .. gallery.config.background_color .. "}") + a:append("{\\1a&" .. gallery.config.background_opacity .. "}") + a:pos(0, 0) + a:draw_start() + a:round_rect_cw( + g.position[1], + g.position[2], + g.position[1] + g.size[1], + g.position[2] + g.size[2], + gallery.config.background_roundness + ) + a:draw_stop() + gallery.ass.background = a.text +end + +function gallery_mt.refresh_placeholders(gallery) + if not gallery.config.show_placeholders then + return + end + if gallery.view.first == 0 then + gallery.ass.placeholders = "" + return + end + local g = gallery.geometry + local a = assdraw.ass_new() + a:new_event() + a:append("{\\an7}") + a:append("{\\bord0}") + a:append("{\\shad0}") + a:append("{\\1c&" .. gallery.config.placeholder_color .. "}") + a:pos(0, 0) + a:draw_start() + for i = 0, gallery.view.last - gallery.view.first do + if gallery.config.always_show_placeholders or not gallery.overlays.active[i + 1] then + local x, y = gallery:view_index_position(i) + a:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2]) + end + end + a:draw_stop() + gallery.ass.placeholders = a.text +end + +function gallery_mt.refresh_scrollbar(gallery) + if not gallery.config.scrollbar then + return + end + gallery.ass.scrollbar = "" + if gallery.view.first == 0 then + return + end + local g = gallery.geometry + local before = (gallery.view.first - 1) / #gallery.items + local after = (#gallery.items - gallery.view.last) / #gallery.items + -- don't show the scrollbar if everything is visible + if before + after == 0 then + return + end + local p = gallery.config.scrollbar_min_size / 100 + if before + after > 1 - p then + if before == 0 then + after = (1 - p) + elseif after == 0 then + before = (1 - p) + else + before, after = + before / after * (1 - p) / (1 + before / after), after / before * (1 - p) / (1 + after / before) + end + end + local dist_from_edge = g.size[2] * 0.015 + local y1 = g.position[2] + dist_from_edge + before * (g.size[2] - 2 * dist_from_edge) + local y2 = g.position[2] + g.size[2] - (dist_from_edge + after * (g.size[2] - 2 * dist_from_edge)) + local x1, x2 + if gallery.config.scrollbar_left_side then + x1 = g.position[1] + g.effective_spacing[1] / 2 - 2 + else + x1 = g.position[1] + g.size[1] - g.effective_spacing[1] / 2 - 2 + end + x2 = x1 + 4 + local scrollbar = assdraw.ass_new() + scrollbar:new_event() + scrollbar:append("{\\an7}") + scrollbar:append("{\\bord0}") + scrollbar:append("{\\shad0}") + scrollbar:append("{\\1c&AAAAAA&}") + scrollbar:pos(0, 0) + scrollbar:draw_start() + scrollbar:rect_cw(x1, y1, x2, y2) + scrollbar:draw_stop() + gallery.ass.scrollbar = scrollbar.text +end + +function gallery_mt.refresh_selection(gallery) + local v = gallery.view + if v.first == 0 then + gallery.ass.selection = "" + return + end + local selection_ass = assdraw.ass_new() + local g = gallery.geometry + local draw_frame = function(index, size, color) + local x, y = gallery:view_index_position(index - v.first) + selection_ass:new_event() + selection_ass:append("{\\an7}") + selection_ass:append("{\\bord" .. size .. "}") + selection_ass:append("{\\3c&" .. color .. "&}") + selection_ass:append("{\\1a&FF&}") + selection_ass:pos(0, 0) + selection_ass:draw_start() + selection_ass:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2]) + selection_ass:draw_stop() + end + for i = v.first, v.last do + local size, color = gallery.item_to_border(i, gallery.items[i]) + if size > 0 then + draw_frame(i, size, color) + end + end + + for index = v.first, v.last do + local text = gallery.item_to_text(index, gallery.items[index]) + if text ~= "" then + selection_ass:new_event() + local an = 5 + local x, y = gallery:view_index_position(index - v.first) + x = x + g.thumbnail_size[1] / 2 + y = y + g.thumbnail_size[2] + gallery.config.text_size * 0.75 + if gallery.config.align_text then + local col = (index - v.first) % g.columns + if g.columns > 1 then + if col == 0 then + x = x - g.thumbnail_size[1] / 2 + an = 4 + elseif col == g.columns - 1 then + x = x + g.thumbnail_size[1] / 2 + an = 6 + end + end + end + selection_ass:an(an) + selection_ass:pos(x, y) + selection_ass:append(string.format("{\\fs%d}", gallery.config.text_size)) + selection_ass:append("{\\bord0}") + selection_ass:append(text) + end + end + gallery.ass.selection = selection_ass.text +end + +function gallery_mt.ass_refresh(gallery, selection, scrollbar, placeholders, background) + if not gallery.active then + return + end + if selection then + gallery:refresh_selection() + end + if scrollbar then + gallery:refresh_scrollbar() + end + if placeholders then + gallery:refresh_placeholders() + end + if background then + gallery:refresh_background() + end + gallery.ass_show(table.concat({ + gallery.ass.background, + gallery.ass.placeholders, + gallery.ass.selection, + gallery.ass.scrollbar, + }, "\n")) +end + +function gallery_mt.set_selection(gallery, selection) + if not selection or selection ~= selection then + return + end + local new_selection = math.max(1, math.min(selection, #gallery.items)) + if gallery.selection == new_selection then + return + end + gallery.selection = new_selection + if gallery.active then + if gallery:ensure_view_valid() then + gallery:refresh_overlays(false) + gallery:ass_refresh(true, true, true, false) + else + gallery:ass_refresh(true, false, false, false) + end + end +end + +function gallery_mt.set_geometry(gallery, x, y, w, h, sw, sh, tw, th) + if w <= 0 or h <= 0 or tw <= 0 or th <= 0 then + msg.warn("Invalid coordinates") + return + end + gallery.geometry.position = { x, y } + gallery.geometry.size = { w, h } + gallery.geometry.min_spacing = { sw, sh } + gallery.geometry.thumbnail_size = { tw, th } + gallery.geometry.ok = true + if not gallery.active then + return + end + if not gallery:enough_space() then + msg.warn("Not enough space to display something") + end + local old_total = gallery.geometry.rows * gallery.geometry.columns + gallery:compute_internal_geometry() + gallery:ensure_view_valid() + local new_total = gallery.geometry.rows * gallery.geometry.columns + for view_index = new_total + 1, old_total do + if gallery.overlays.active[view_index] then + mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1) + gallery.overlays.active[view_index] = false + end + end + gallery:refresh_overlays(true) + gallery:ass_refresh(true, true, true, true) +end + +function gallery_mt.items_changed(gallery, new_sel) + gallery.selection = math.max(1, math.min(new_sel, #gallery.items)) + if not gallery.active then + return + end + gallery:ensure_view_valid() + gallery:refresh_overlays(false) + gallery:ass_refresh(true, true, true, false) +end + +function gallery_mt.thumbnail_generated(gallery, thumb_path) + if not gallery.active then + return + end + local view_index = gallery.overlays.missing[thumb_path] + if view_index == nil then + return + end + gallery:show_overlay(view_index, thumb_path) + if not gallery.config.always_show_placeholders then + gallery:ass_refresh(false, false, true, false) + end + gallery.overlays.missing[thumb_path] = nil +end + +function gallery_mt.add_generator(gallery, generator_name) + for _, g in ipairs(gallery.generators) do + if generator_name == g then + return + end + end + gallery.generators[#gallery.generators + 1] = generator_name +end + +function gallery_mt.view_index_position(gallery, index_0) + local g = gallery.geometry + return math.floor( + g.position[1] + g.effective_spacing[1] + (g.effective_spacing[1] + g.thumbnail_size[1]) * (index_0 % g.columns) + ), + math.floor( + g.position[2] + + g.effective_spacing[2] + + (g.effective_spacing[2] + g.thumbnail_size[2]) * math.floor(index_0 / g.columns) + ) +end + +function gallery_mt.enough_space(gallery) + if gallery.geometry.size[1] < gallery.geometry.thumbnail_size[1] + 2 * gallery.geometry.min_spacing[1] then + return false + end + if gallery.geometry.size[2] < gallery.geometry.thumbnail_size[2] + 2 * gallery.geometry.min_spacing[2] then + return false + end + return true +end + +function gallery_mt.activate(gallery) + if gallery.active then + return false + end + if not gallery:enough_space() then + msg.warn("Not enough space, refusing to start") + return false + end + if not gallery.geometry.ok then + msg.warn("Gallery geometry unitialized, refusing to start") + return false + end + gallery.active = true + if not gallery.selection then + gallery:set_selection(1) + end + gallery:compute_internal_geometry() + gallery:ensure_view_valid() + gallery:refresh_overlays(false) + gallery:ass_refresh(true, true, true, true) + return true +end + +function gallery_mt.deactivate(gallery) + if not gallery.active then + return + end + gallery.active = false + gallery:remove_overlays() + gallery.ass_show("") +end + +return { gallery_new = gallery_new } diff --git a/mac/.config/mpv/script-modules/input-console.lua b/mac/.config/mpv/script-modules/input-console.lua new file mode 100644 index 0000000..9128cba --- /dev/null +++ b/mac/.config/mpv/script-modules/input-console.lua @@ -0,0 +1,935 @@ +-- copied from here: https://github.com/mpv-player/mpv/blob/ebaf6a6cfa24a78d9041389974568b9db7df6a71/player/lua/console.lua + +-- Copyright (C) 2019 the mpv developers +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +local utils = require("mp.utils") +local options = require("mp.options") +local assdraw = require("mp.assdraw") + +-- Default options +local opts = { + -- All drawing is scaled by this value, including the text borders and the + -- cursor. Change it if you have a high-DPI display. + scale = 1.3, + -- Set the font used for the REPL and the console. This probably doesn't + -- have to be a monospaced font. + font = "", + -- Set the font size used for the REPL and the console. This will be + -- multiplied by "scale." + font_size = 16, +} + +function detect_platform() + local o = {} + -- Kind of a dumb way of detecting the platform but whatever + if mp.get_property_native("options/vo-mmcss-profile", o) ~= o then + return "windows" + elseif mp.get_property_native("options/macos-force-dedicated-gpu", o) ~= o then + return "macos" + elseif os.getenv("WAYLAND_DISPLAY") then + return "wayland" + end + return "x11" +end + +-- Pick a better default font for Windows and macOS +local platform = detect_platform() +if platform == "windows" then + opts.font = "Consolas" +elseif platform == "macos" then + opts.font = "Menlo" +else + opts.font = "monospace" +end + +-- Apply user-set options +options.read_options(opts) + +local repl_active = false +local insert_mode = false +local pending_update = false +local line = "" +local cursor = 1 +local history = {} +local history_pos = 1 +local log_buffer = {} +local key_bindings = {} +local global_margin_y = 0 + +local update_timer = nil +update_timer = mp.add_periodic_timer(0.05, function() + if pending_update then + update() + else + update_timer:kill() + end +end) +update_timer:kill() + +if utils.shared_script_property_observe then + utils.shared_script_property_observe("osc-margins", function(_, val) + if val then + -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each + -- value being the border size as ratio of the window size (0.0-1.0) + local vals = {} + for v in string.gmatch(val, "[^,]+") do + vals[#vals + 1] = tonumber(v) + end + global_margin_y = vals[4] -- bottom + else + global_margin_y = 0 + end + update() + end) +else + mp.observe_property("user-data/osc/margins", "native", function(_, val) + if val then + global_margin_y = val.b + else + global_margin_y = 0 + end + end) +end + +-- Add a line to the log buffer (which is limited to 100 lines) +function log_add(style, text) + log_buffer[#log_buffer + 1] = { style = style, text = text } + if #log_buffer > 100 then + table.remove(log_buffer, 1) + end + + if repl_active then + if not update_timer:is_enabled() then + update() + update_timer:resume() + else + pending_update = true + end + end +end + +-- Escape a string for verbatim display on the OSD +function ass_escape(str) + -- 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 + str = str:gsub("\\", "\\\239\187\191") + str = str:gsub("{", "\\{") + str = str:gsub("}", "\\}") + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub("\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") + return str +end + +-- Render the REPL and console as an ASS OSD +function update() + pending_update = false + + local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) + + dpi_scale = dpi_scale * opts.scale + + local screenx, screeny, aspect = mp.get_osd_size() + screenx = screenx / dpi_scale + screeny = screeny / dpi_scale + + -- Clear the OSD if the REPL is not active + if not repl_active then + mp.set_osd_ass(screenx, screeny, "") + return + end + + local ass = assdraw.ass_new() + local style = "{\\r" + .. "\\1a&H00&\\3a&H00&\\4a&H99&" + .. "\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&" + .. "\\fn" + .. opts.font + .. "\\fs" + .. opts.font_size + .. "\\bord1\\xshad0\\yshad1\\fsp0\\q1}" + -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor + -- inline with the surrounding text, but it sets the advance to the width + -- of the drawing. So the cursor doesn't affect layout too much, make it as + -- thin as possible and make it appear to be 1px wide by giving it 0.5px + -- horizontal borders. + local cheight = opts.font_size * 8 + local cglyph = "{\\r" + .. "\\1a&H44&\\3a&H44&\\4a&H99&" + .. "\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&" + .. "\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}" + .. "m 0 0 l 1 0 l 1 " + .. cheight + .. " l 0 " + .. cheight + .. "{\\p0}" + local before_cur = ass_escape(line:sub(1, cursor - 1)) + local after_cur = ass_escape(line:sub(cursor)) + + -- Render log messages as ASS. This will render at most screeny / font_size + -- messages. + local log_ass = "" + local log_messages = #log_buffer + local log_max_lines = math.ceil(screeny / opts.font_size) + if log_max_lines < log_messages then + log_messages = log_max_lines + end + for i = #log_buffer - log_messages + 1, #log_buffer do + log_ass = log_ass .. style .. log_buffer[i].style .. ass_escape(log_buffer[i].text) + end + + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margin_y * screeny) + ass:append(log_ass .. "\\N") + ass:append(style .. "> " .. before_cur) + ass:append(cglyph) + ass:append(style .. after_cur) + + -- Redraw the cursor with the REPL text invisible. This will make the + -- cursor appear in front of the text. + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margin_y * screeny) + ass:append(style .. "{\\alpha&HFF&}> " .. before_cur) + ass:append(cglyph) + ass:append(style .. "{\\alpha&HFF&}" .. after_cur) + + mp.set_osd_ass(screenx, screeny, ass.text) +end + +-- Set the REPL visibility ("enable", Esc) +function set_active(active) + if active == repl_active then + return + end + if active then + repl_active = true + insert_mode = false + mp.enable_key_bindings("console-input", "allow-hide-cursor+allow-vo-dragging") + mp.enable_messages("terminal-default") + define_key_bindings() + else + repl_active = false + undefine_key_bindings() + mp.enable_messages("silent:terminal-default") + collectgarbage() + end + update() +end + +-- Show the repl if hidden and replace its contents with 'text' +-- (script-message-to repl type) +function show_and_type(text, cursor_pos) + text = text or "" + cursor_pos = tonumber(cursor_pos) + + -- Save the line currently being edited, just in case + if line ~= text and line ~= "" and history[#history] ~= line then + history[#history + 1] = line + end + + line = text + if cursor_pos ~= nil and cursor_pos >= 1 and cursor_pos <= line:len() + 1 then + cursor = math.floor(cursor_pos) + else + cursor = line:len() + 1 + end + history_pos = #history + 1 + insert_mode = false + if repl_active then + update() + else + set_active(true) + end +end + +-- Naive helper function to find the next UTF-8 character in 'str' after 'pos' +-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. +function next_utf8(str, pos) + if pos > str:len() then + return pos + end + repeat + pos = pos + 1 + until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- As above, but finds the previous UTF-8 character in 'str' before 'pos' +function prev_utf8(str, pos) + if pos <= 1 then + return pos + end + repeat + pos = pos - 1 + until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- Insert a character at the current cursor position (any_unicode) +function handle_char_input(c) + if insert_mode then + line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) + else + line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) + end + cursor = cursor + #c + update() +end + +-- Remove the character behind the cursor (Backspace) +function handle_backspace() + if cursor <= 1 then + return + end + local prev = prev_utf8(line, cursor) + line = line:sub(1, prev - 1) .. line:sub(cursor) + cursor = prev + update() +end + +-- Remove the character in front of the cursor (Del) +function handle_del() + if cursor > line:len() then + return + end + line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) + update() +end + +-- Toggle insert mode (Ins) +function handle_ins() + insert_mode = not insert_mode +end + +-- Move the cursor to the next character (Right) +function next_char(amount) + cursor = next_utf8(line, cursor) + update() +end + +-- Move the cursor to the previous character (Left) +function prev_char(amount) + cursor = prev_utf8(line, cursor) + update() +end + +-- Clear the current line (Ctrl+C) +function clear() + line = "" + cursor = 1 + insert_mode = false + history_pos = #history + 1 + update() +end + +-- Close the REPL if the current line is empty, otherwise delete the next +-- character (Ctrl+D) +function maybe_exit() + if line == "" then + set_active(false) + else + handle_del() + end +end + +function help_command(param) + local cmdlist = mp.get_property_native("command-list") + local error_style = "{\\1c&H7a77f2&}" + local output = "" + if param == "" then + output = "Available commands:\n" + for _, cmd in ipairs(cmdlist) do + output = output .. " " .. cmd.name + end + output = output .. "\n" + output = output .. 'Use "help command" to show information about a command.\n' + output = output .. "ESC or Ctrl+d exits the console.\n" + else + local cmd = nil + for _, curcmd in ipairs(cmdlist) do + if curcmd.name:find(param, 1, true) then + cmd = curcmd + if curcmd.name == param then + break -- exact match + end + end + end + if not cmd then + log_add(error_style, 'No command matches "' .. param .. '"!') + return + end + output = output .. 'Command "' .. cmd.name .. '"\n' + for _, arg in ipairs(cmd.args) do + output = output .. " " .. arg.name .. " (" .. arg.type .. ")" + if arg.optional then + output = output .. " (optional)" + end + output = output .. "\n" + end + if cmd.vararg then + output = output .. "This command supports variable arguments.\n" + end + end + log_add("", output) +end + +local enter_handler = nil + +-- Run the current command and clear the line (Enter) +function handle_enter() + if line == "" then + return + end + + if history[#history] ~= line then + history[#history + 1] = line + end + + if enter_handler then + enter_handler(line) + end + + set_active(false) + + clear() +end + +-- Go to the specified position in the command history +function go_history(new_pos) + local old_pos = history_pos + history_pos = new_pos + + -- Restrict the position to a legal value + if history_pos > #history + 1 then + history_pos = #history + 1 + elseif history_pos < 1 then + history_pos = 1 + end + + -- Do nothing if the history position didn't actually change + if history_pos == old_pos then + return + end + + -- If the user was editing a non-history line, save it as the last history + -- entry. This makes it much less frustrating to accidentally hit Up/Down + -- while editing a line. + if old_pos == #history + 1 and line ~= "" and history[#history] ~= line then + history[#history + 1] = line + end + + -- Now show the history line (or a blank line for #history + 1) + if history_pos <= #history then + line = history[history_pos] + else + line = "" + end + cursor = line:len() + 1 + insert_mode = false + update() +end + +-- Go to the specified relative position in the command history (Up, Down) +function move_history(amount) + go_history(history_pos + amount) +end + +-- Go to the first command in the command history (PgUp) +function handle_pgup() + go_history(1) +end + +-- Stop browsing history and start editing a blank line (PgDown) +function handle_pgdown() + go_history(#history + 1) +end + +-- Move to the start of the current word, or if already at the start, the start +-- of the previous word. (Ctrl+Left) +function prev_word() + -- This is basically the same as next_word() but backwards, so reverse the + -- string in order to do a "backwards" find. This wouldn't be as annoying + -- to do if Lua didn't insist on 1-based indexing. + cursor = line:len() - select(2, line:reverse():find("%s*[^%s]*", line:len() - cursor + 2)) + 1 + update() +end + +-- Move to the end of the current word, or if already at the end, the end of +-- the next word. (Ctrl+Right) +function next_word() + cursor = select(2, line:find("%s*[^%s]*", cursor)) + 1 + update() +end + +-- List of tab-completions: +-- pattern: A Lua pattern used in string:find. Should return the start and +-- end positions of the word to be completed in the first and second +-- capture groups (using the empty parenthesis notation "()") +-- list: A list of candidate completion values. +-- append: An extra string to be appended to the end of a successful +-- completion. It is only appended if 'list' contains exactly one +-- match. +function build_completers() + -- Build a list of commands, properties and options for tab-completion + local option_info = { + "name", + "type", + "set-from-commandline", + "set-locally", + "default-value", + "min", + "max", + "choices", + } + local cmd_list = {} + for i, cmd in ipairs(mp.get_property_native("command-list")) do + cmd_list[i] = cmd.name + end + local prop_list = mp.get_property_native("property-list") + for _, opt in ipairs(mp.get_property_native("options")) do + prop_list[#prop_list + 1] = "options/" .. opt + prop_list[#prop_list + 1] = "file-local-options/" .. opt + prop_list[#prop_list + 1] = "option-info/" .. opt + for _, p in ipairs(option_info) do + prop_list[#prop_list + 1] = "option-info/" .. opt .. "/" .. p + end + end + + return { + { pattern = "^%s*()[%w_-]+()$", list = cmd_list, append = " " }, + { pattern = "^%s*set%s+()[%w_/-]+()$", list = prop_list, append = " " }, + { pattern = '^%s*set%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, + { pattern = "^%s*add%s+()[%w_/-]+()$", list = prop_list, append = " " }, + { pattern = '^%s*add%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, + { pattern = "^%s*cycle%s+()[%w_/-]+()$", list = prop_list, append = " " }, + { pattern = '^%s*cycle%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, + { pattern = "^%s*multiply%s+()[%w_/-]+()$", list = prop_list, append = " " }, + { pattern = '^%s*multiply%s+"()[%w_/-]+()$', list = prop_list, append = '" ' }, + { pattern = "${()[%w_/-]+()$", list = prop_list, append = "}" }, + } +end + +-- Use 'list' to find possible tab-completions for 'part.' Returns the longest +-- common prefix of all the matching list items and a flag that indicates +-- whether the match was unique or not. +function complete_match(part, list) + local completion = nil + local full_match = false + + for _, candidate in ipairs(list) do + if candidate:sub(1, part:len()) == part then + if completion and completion ~= candidate then + local prefix_len = part:len() + while completion:sub(1, prefix_len + 1) == candidate:sub(1, prefix_len + 1) do + prefix_len = prefix_len + 1 + end + completion = candidate:sub(1, prefix_len) + full_match = false + else + completion = candidate + full_match = true + end + end + end + + return completion, full_match +end + +-- Complete the option or property at the cursor (TAB) +function complete() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + -- Try the first completer that works + for _, completer in ipairs(build_completers()) do + -- Completer patterns should return the start and end of the word to be + -- completed as the first and second capture groups + local _, _, s, e = before_cur:find(completer.pattern) + if not s then + -- Multiple input commands can be separated by semicolons, so all + -- completions that are anchored at the start of the string with + -- '^' can start from a semicolon as well. Replace ^ with ; and try + -- to match again. + _, _, s, e = before_cur:find(completer.pattern:gsub("^^", ";")) + end + if s then + -- If the completer's pattern found a word, check the completer's + -- list for possible completions + local part = before_cur:sub(s, e) + local c, full = complete_match(part, completer.list) + if c then + -- If there was only one full match from the list, add + -- completer.append to the final string. This is normally a + -- space or a quotation mark followed by a space. + if full and completer.append then + c = c .. completer.append + end + + -- Insert the completion and update + before_cur = before_cur:sub(1, s - 1) .. c + cursor = before_cur:len() + 1 + line = before_cur .. after_cur + update() + return + end + end + end +end + +-- Move the cursor to the beginning of the line (HOME) +function go_home() + cursor = 1 + update() +end + +-- Move the cursor to the end of the line (END) +function go_end() + cursor = line:len() + 1 + update() +end + +-- Delete from the cursor to the beginning of the word (Ctrl+Backspace) +function del_word() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + before_cur = before_cur:gsub("[^%s]+%s*$", "", 1) + line = before_cur .. after_cur + cursor = before_cur:len() + 1 + update() +end + +-- Delete from the cursor to the end of the word (Ctrl+Del) +function del_next_word() + if cursor > line:len() then + return + end + + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + after_cur = after_cur:gsub("^%s*[^%s]+", "", 1) + line = before_cur .. after_cur + update() +end + +-- Delete from the cursor to the end of the line (Ctrl+K) +function del_to_eol() + line = line:sub(1, cursor - 1) + update() +end + +-- Delete from the cursor back to the start of the line (Ctrl+U) +function del_to_start() + line = line:sub(cursor) + cursor = 1 + update() +end + +-- Empty the log buffer of all messages (Ctrl+L) +function clear_log_buffer() + log_buffer = {} + update() +end + +-- Returns a string of UTF-8 text from the clipboard (or the primary selection) +function get_clipboard(clip) + if platform == "x11" then + local res = utils.subprocess({ + args = { "xclip", "-selection", clip and "clipboard" or "primary", "-out" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "wayland" then + local res = utils.subprocess({ + args = { "wl-paste", clip and "-n" or "-np" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "windows" then + local res = utils.subprocess({ + args = { + "powershell", + "-NoProfile", + "-Command", + [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + + $clip = "" + if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + } else { + Add-Type -AssemblyName PresentationCore + $clip = [Windows.Clipboard]::GetText() + } + + $clip = $clip -Replace "`r","" + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]], + }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "macos" then + local res = utils.subprocess({ + args = { "pbpaste" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + end + return "" +end + +-- Paste text from the window-system's clipboard. 'clip' determines whether the +-- clipboard or the primary selection buffer is used (on X11 and Wayland only.) +function paste(clip) + local text = get_clipboard(clip) + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + line = before_cur .. text .. after_cur + cursor = cursor + text:len() + update() +end + +-- List of input bindings. This is a weird mashup between common GUI text-input +-- bindings and readline bindings. +function get_bindings() + local bindings = { + { + "esc", + function() + set_active(false) + end, + }, + { "enter", handle_enter }, + { "kp_enter", handle_enter }, + { + "shift+enter", + function() + handle_char_input("\n") + end, + }, + { "ctrl+j", handle_enter }, + { "ctrl+m", handle_enter }, + { "bs", handle_backspace }, + { "shift+bs", handle_backspace }, + { "ctrl+h", handle_backspace }, + { "del", handle_del }, + { "shift+del", handle_del }, + { "ins", handle_ins }, + { + "shift+ins", + function() + paste(false) + end, + }, + { + "mbtn_mid", + function() + paste(false) + end, + }, + { + "left", + function() + prev_char() + end, + }, + { + "ctrl+b", + function() + prev_char() + end, + }, + { + "right", + function() + next_char() + end, + }, + { + "ctrl+f", + function() + next_char() + end, + }, + { + "up", + function() + move_history(-1) + end, + }, + { + "ctrl+p", + function() + move_history(-1) + end, + }, + { + "wheel_up", + function() + move_history(-1) + end, + }, + { + "down", + function() + move_history(1) + end, + }, + { + "ctrl+n", + function() + move_history(1) + end, + }, + { + "wheel_down", + function() + move_history(1) + end, + }, + { "wheel_left", function() end }, + { "wheel_right", function() end }, + { "ctrl+left", prev_word }, + { "alt+b", prev_word }, + { "ctrl+right", next_word }, + { "alt+f", next_word }, + { "tab", complete }, + { "ctrl+i", complete }, + { "ctrl+a", go_home }, + { "home", go_home }, + { "ctrl+e", go_end }, + { "end", go_end }, + { "pgup", handle_pgup }, + { "pgdwn", handle_pgdown }, + { "ctrl+c", clear }, + { "ctrl+d", maybe_exit }, + { "ctrl+k", del_to_eol }, + { "ctrl+l", clear_log_buffer }, + { "ctrl+u", del_to_start }, + { + "ctrl+v", + function() + paste(true) + end, + }, + { + "meta+v", + function() + paste(true) + end, + }, + { "ctrl+bs", del_word }, + { "ctrl+w", del_word }, + { "ctrl+del", del_next_word }, + { "alt+d", del_next_word }, + { + "kp_dec", + function() + handle_char_input(".") + end, + }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = { + "kp" .. i, + function() + handle_char_input("" .. i) + end, + } + end + + return bindings +end + +local function text_input(info) + if info.key_text and (info.event == "press" or info.event == "down" or info.event == "repeat") then + handle_char_input(info.key_text) + end +end + +function define_key_bindings() + if #key_bindings > 0 then + return + end + for _, bind in ipairs(get_bindings()) do + -- Generate arbitrary name for removing the bindings later. + local name = "_console_" .. (#key_bindings + 1) + key_bindings[#key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2], { repeatable = true }) + end + mp.add_forced_key_binding("any_unicode", "_console_text", text_input, { repeatable = true, complex = true }) + key_bindings[#key_bindings + 1] = "_console_text" +end + +function undefine_key_bindings() + for _, name in ipairs(key_bindings) do + mp.remove_key_binding(name) + end + key_bindings = {} +end + +-- Add a global binding for enabling the REPL. While it's enabled, its bindings +-- will take over and it can be closed with ESC. +mp.add_key_binding(nil, "enable", function() + set_active(true) +end) + +-- Add a script-message to show the REPL and fill it with the provided text +mp.register_script_message("type", function(text, cursor_pos) + show_and_type(text, cursor_pos) +end) + +-- Redraw the REPL when the OSD size changes. This is needed because the +-- PlayRes of the OSD will need to be adjusted. +mp.observe_property("osd-width", "native", update) +mp.observe_property("osd-height", "native", update) +mp.observe_property("display-hidpi-scale", "native", update) + +-- Enable log messages. In silent mode, mpv will queue log messages in a buffer +-- until enable_messages is called again without the silent: prefix. +mp.enable_messages("silent:terminal-default") + +collectgarbage() + +return { + set_active = set_active, + is_repl_active = function() + return repl_active + end, + set_enter_handler = function(callback) + enter_handler = callback + end, +} diff --git a/mac/.config/mpv/script-modules/mpvSockets.lua b/mac/.config/mpv/script-modules/mpvSockets.lua new file mode 100644 index 0000000..d745540 --- /dev/null +++ b/mac/.config/mpv/script-modules/mpvSockets.lua @@ -0,0 +1,36 @@ +-- mpvSockets, one socket per instance, removes socket on exit + +local utils = require("mp.utils") + +local function get_temp_path() + local directory_seperator = package.config:match("([^\n]*)\n?") + local example_temp_file_path = os.tmpname() + + -- remove generated temp file + pcall(os.remove, example_temp_file_path) + + local seperator_idx = example_temp_file_path:reverse():find(directory_seperator) + local temp_path_length = #example_temp_file_path - seperator_idx + + return example_temp_file_path:sub(1, temp_path_length) +end + +tempDir = get_temp_path() + +function join_paths(...) + local arg = { ... } + path = "" + for i, v in ipairs(arg) do + path = utils.join_path(path, tostring(v)) + end + return path +end + +ppid = utils.getpid() +os.execute("mkdir " .. join_paths(tempDir, "mpvSockets") .. " 2>/dev/null") +mp.set_property("options/input-ipc-server", join_paths(tempDir, "mpvSockets", ppid)) + +function shutdown_handler() + os.remove(join_paths(tempDir, "mpvSockets", ppid)) +end +mp.register_event("shutdown", shutdown_handler) diff --git a/mac/.config/mpv/script-modules/scroll-list.lua b/mac/.config/mpv/script-modules/scroll-list.lua new file mode 100644 index 0000000..5d8f9fa --- /dev/null +++ b/mac/.config/mpv/script-modules/scroll-list.lua @@ -0,0 +1,293 @@ +local mp = require 'mp' +local scroll_list = { + global_style = [[]], + header_style = [[{\q2\fs35\c&00ccff&}]], + list_style = [[{\q2\fs25\c&Hffffff&}]], + wrapper_style = [[{\c&00ccff&\fs16}]], + cursor_style = [[{\c&00ccff&}]], + selected_style = [[{\c&Hfce788&}]], + + cursor = [[➤\h]], + indent = [[\h\h\h\h]], + + num_entries = 16, + wrap = false, + empty_text = "no entries" +} + +--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 scroll_list.ass_escape(str, replace_newline) + if replace_newline == true then replace_newline = "\\\239\187\191n" end + + --escape the invalid single characters + str = str:gsub('[\\{}\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 + +--format and return the header string +function scroll_list:format_header_string(str) + return str +end + +--appends the entered text to the overlay +function scroll_list:append(text) + if text == nil then return end + self.ass.data = self.ass.data .. text + end + +--appends a newline character to the osd +function scroll_list:newline() + self.ass.data = self.ass.data .. '\\N' +end + +--re-parses the list into an ass string +--if the list is closed then it flags an update on the next open +function scroll_list:update() + if self.hidden then self.flag_update = true + else self:update_ass() end +end + +--prints the header to the overlay +function scroll_list:format_header() + self:append(self.header_style) + self:append(self:format_header_string(self.header)) + self:newline() +end + +--formats each line of the list and prints it to the overlay +function scroll_list:format_line(index, item) + self:append(self.list_style) + + if index == self.selected then self:append(self.cursor_style..self.cursor..self.selected_style) + else self:append(self.indent) end + + self:append(item.style) + self:append(item.ass) + self:newline() +end + +--refreshes the ass text using the contents of the list +function scroll_list:update_ass() + self.ass.data = self.global_style + self:format_header() + + if #self.list < 1 then + self:append(self.empty_text) + self.ass:update() + return + end + + local start = 1 + local finish = start+self.num_entries-1 + + --handling cursor positioning + local mid = math.ceil(self.num_entries/2)+1 + if self.selected+mid > finish then + local offset = self.selected - finish + mid + + --if we've overshot the end of the list then undo some of the offset + if finish + offset > #self.list then + offset = offset - ((finish+offset) - #self.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 < #self.list + --this is necessary when the number of items in the dir is less than the max + if not overflow then finish = #self.list end + + --adding a header to show there are items above in the list + if start > 1 then self:append(self.wrapper_style..(start-1)..' item(s) above\\N\\N') end + + for i=start, finish do + self:format_line(i, self.list[i]) + end + + if overflow then self:append('\\N'..self.wrapper_style..#self.list-finish..' item(s) remaining') end + self.ass:update() +end + +--moves the selector down the list +function scroll_list:scroll_down() + if self.selected < #self.list then + self.selected = self.selected + 1 + self:update_ass() + elseif self.wrap then + self.selected = 1 + self:update_ass() + end +end + +--moves the selector up the list +function scroll_list:scroll_up() + if self.selected > 1 then + self.selected = self.selected - 1 + self:update_ass() + elseif self.wrap then + self.selected = #self.list + self:update_ass() + end +end + +--moves the selector to the list next page +function scroll_list:move_pagedown() + if #self.list > self.num_entries then + self.selected = self.selected + self.num_entries + if self.selected > #self.list then self.selected = #self.list end + self:update_ass() + end +end + +--moves the selector to the list previous page +function scroll_list:move_pageup() + if #self.list > self.num_entries then + self.selected = self.selected - self.num_entries + if self.selected < 1 then self.selected = 1 end + self:update_ass() + end +end + +--moves the selector to the list begin +function scroll_list:move_begin() + if #self.list > 1 then + self.selected = 1 + self:update_ass() + end +end + +--moves the selector to the list end +function scroll_list:move_end() + if #self.list > 1 then + self.selected = #self.list + self:update_ass() + end +end + +--adds the forced keybinds +function scroll_list:add_keybinds() + for _,v in ipairs(self.keybinds) do + mp.add_forced_key_binding(v[1], 'dynamic/'..self.ass.id..'/'..v[2], v[3], v[4]) + end +end + +--removes the forced keybinds +function scroll_list:remove_keybinds() + for _,v in ipairs(self.keybinds) do + mp.remove_key_binding('dynamic/'..self.ass.id..'/'..v[2]) + end +end + +--opens the list and sets the hidden flag +function scroll_list:open_list() + self.hidden = false + if not self.flag_update then self.ass:update() + else self.flag_update = false ; self:update_ass() end +end + +--closes the list and sets the hidden flag +function scroll_list:close_list() + self.hidden = true + self.ass:remove() +end + +--modifiable function that opens the list +function scroll_list:open() + if self.hidden then self:add_keybinds() end + self:open_list() +end + +--modifiable function that closes the list +function scroll_list:close() + self:remove_keybinds() + self:close_list() +end + +--toggles the list +function scroll_list:toggle() + if self.hidden then self:open() + else self:close() end +end + +--clears the list in-place +function scroll_list:clear() + local i = 1 + while self.list[i] do + self.list[i] = nil + i = i + 1 + end +end + +--added alias for ipairs(list.list) for lua 5.1 +function scroll_list:ipairs() + return ipairs(self.list) +end + +--append item to the end of the list +function scroll_list:insert(item) + self.list[#self.list + 1] = item +end + +local metatable = { + __index = function(t, key) + if scroll_list[key] ~= nil then return scroll_list[key] + elseif key == "__current" then return t.list[t.selected] + elseif type(key) == "number" then return t.list[key] end + end, + __newindex = function(t, key, value) + if type(key) == "number" then rawset(t.list, key, value) + else rawset(t, key, value) end + end, + __scroll_list = scroll_list, + __len = function(t) return #t.list end, + __ipairs = function(t) return ipairs(t.list) end +} + +--creates a new list object +function scroll_list:new() + local vars + vars = { + ass = mp.create_osd_overlay('ass-events'), + hidden = true, + flag_update = true, + + header = "header \\N ----------------------------------------------", + list = {}, + selected = 1, + + keybinds = { + {'DOWN', 'scroll_down', function() vars:scroll_down() end, {repeatable = true}}, + {'UP', 'scroll_up', function() vars:scroll_up() end, {repeatable = true}}, + {'PGDWN', 'move_pagedown', function() vars:move_pagedown() end, {}}, + {'PGUP', 'move_pageup', function() vars:move_pageup() end, {}}, + {'HOME', 'move_begin', function() vars:move_begin() end, {}}, + {'END', 'move_end', function() vars:move_end() end, {}}, + {'ESC', 'close_browser', function() vars:close() end, {}} + } + } + return setmetatable(vars, metatable) +end + +return scroll_list:new() diff --git a/mac/.config/mpv/script-modules/sha1.lua b/mac/.config/mpv/script-modules/sha1.lua new file mode 100644 index 0000000..6b19396 --- /dev/null +++ b/mac/.config/mpv/script-modules/sha1.lua @@ -0,0 +1,334 @@ +-- $Revision: 1.5 $ +-- $Date: 2014-09-10 16:54:25 $ + +-- This module was originally taken from http://cube3d.de/uploads/Main/sha1.txt. + +------------------------------------------------------------------------------- +-- SHA-1 secure hash computation, and HMAC-SHA1 signature computation, +-- in pure Lua (tested on Lua 5.1) +-- License: MIT +-- +-- Usage: +-- local hashAsHex = sha1.hex(message) -- returns a hex string +-- local hashAsData = sha1.bin(message) -- returns raw bytes +-- +-- local hmacAsHex = sha1.hmacHex(key, message) -- hex string +-- local hmacAsData = sha1.hmacBin(key, message) -- raw bytes +-- +-- +-- Pass sha1.hex() a string, and it returns a hash as a 40-character hex string. +-- For example, the call +-- +-- local hash = sha1.hex("iNTERFACEWARE") +-- +-- puts the 40-character string +-- +-- "e76705ffb88a291a0d2f9710a5471936791b4819" +-- +-- into the variable 'hash' +-- +-- Pass sha1.hmacHex() a key and a message, and it returns the signature as a +-- 40-byte hex string. +-- +-- +-- The two "bin" versions do the same, but return the 20-byte string of raw +-- data that the 40-byte hex strings represent. +-- +------------------------------------------------------------------------------- +-- +-- Description +-- Due to the lack of bitwise operations in 5.1, this version uses numbers to +-- represents the 32bit words that we combine with binary operations. The basic +-- operations of byte based "xor", "or", "and" are all cached in a combination +-- table (several 64k large tables are built on startup, which +-- consumes some memory and time). The caching can be switched off through +-- setting the local cfg_caching variable to false. +-- For all binary operations, the 32 bit numbers are split into 8 bit values +-- that are combined and then merged again. +-- +-- Algorithm: http://www.itl.nist.gov/fipspubs/fip180-1.htm +-- +------------------------------------------------------------------------------- + +sha1 = {} + +-- set this to false if you don't want to build several 64k sized tables when +-- loading this file (takes a while but grants a boost of factor 13) +local cfg_caching = false + +-- local storing of global functions (minor speedup) +local floor, modf = math.floor, math.modf +local char, format, rep = string.char, string.format, string.rep + +-- merge 4 bytes to an 32 bit word +local function bytes_to_w32(a, b, c, d) return a * 0x1000000 + b * 0x10000 + c * 0x100 + d end + +-- split a 32 bit word into four 8 bit numbers +local function w32_to_bytes(i) + return floor(i / 0x1000000) % 0x100, floor(i / 0x10000) % 0x100, floor(i / 0x100) % 0x100, i % 0x100 +end + +-- shift the bits of a 32 bit word. Don't use negative values for "bits" +local function w32_rot(bits, a) + local b2 = 2 ^ (32 - bits) + local a, b = modf(a / b2) + return a + b * b2 * (2 ^ (bits)) +end + +-- caching function for functions that accept 2 arguments, both of values between +-- 0 and 255. The function to be cached is passed, all values are calculated +-- during loading and a function is returned that returns the cached values (only) +local function cache2arg(fn) + if not cfg_caching then return fn end + local lut = {} + for i = 0, 0xffff do + local a, b = floor(i / 0x100), i % 0x100 + lut[i] = fn(a, b) + end + return function(a, b) + return lut[a * 0x100 + b] + end +end + +-- splits an 8-bit number into 8 bits, returning all 8 bits as booleans +local function byte_to_bits(b) + local b = function(n) + local b = floor(b / n) + return b % 2 == 1 + end + return b(1), b(2), b(4), b(8), b(16), b(32), b(64), b(128) +end + +-- builds an 8bit number from 8 booleans +local function bits_to_byte(a, b, c, d, e, f, g, h) + local function n(b, x) return b and x or 0 end + + return n(a, 1) + n(b, 2) + n(c, 4) + n(d, 8) + n(e, 16) + n(f, 32) + n(g, 64) + n(h, 128) +end + +-- debug function for visualizing bits in a string +local function bits_to_string(a, b, c, d, e, f, g, h) + local function x(b) return b and "1" or "0" end + + return ("%s%s%s%s %s%s%s%s"):format(x(a), x(b), x(c), x(d), x(e), x(f), x(g), x(h)) +end + +-- debug function for converting a 8-bit number as bit string +local function byte_to_bit_string(b) + return bits_to_string(byte_to_bits(b)) +end + +-- debug function for converting a 32 bit number as bit string +local function w32_to_bit_string(a) + if type(a) == "string" then return a end + local aa, ab, ac, ad = w32_to_bytes(a) + local s = byte_to_bit_string + return ("%s %s %s %s"):format(s(aa):reverse(), s(ab):reverse(), s(ac):reverse(), s(ad):reverse()):reverse() +end + +-- bitwise "and" function for 2 8bit number +local band = cache2arg(function(a, b) + local A, B, C, D, E, F, G, H = byte_to_bits(b) + local a, b, c, d, e, f, g, h = byte_to_bits(a) + return bits_to_byte( + A and a, B and b, C and c, D and d, + E and e, F and f, G and g, H and h) +end) + +-- bitwise "or" function for 2 8bit numbers +local bor = cache2arg(function(a, b) + local A, B, C, D, E, F, G, H = byte_to_bits(b) + local a, b, c, d, e, f, g, h = byte_to_bits(a) + return bits_to_byte( + A or a, B or b, C or c, D or d, + E or e, F or f, G or g, H or h) +end) + +-- bitwise "xor" function for 2 8bit numbers +local bxor = cache2arg(function(a, b) + local A, B, C, D, E, F, G, H = byte_to_bits(b) + local a, b, c, d, e, f, g, h = byte_to_bits(a) + return bits_to_byte( + A ~= a, B ~= b, C ~= c, D ~= d, + E ~= e, F ~= f, G ~= g, H ~= h) +end) + +-- bitwise complement for one 8bit number +local function bnot(x) + return 255 - (x % 256) +end + +-- creates a function to combine to 32bit numbers using an 8bit combination function +local function w32_comb(fn) + return function(a, b) + local aa, ab, ac, ad = w32_to_bytes(a) + local ba, bb, bc, bd = w32_to_bytes(b) + return bytes_to_w32(fn(aa, ba), fn(ab, bb), fn(ac, bc), fn(ad, bd)) + end +end + +-- create functions for and, xor and or, all for 2 32bit numbers +local w32_and = w32_comb(band) +local w32_xor = w32_comb(bxor) +local w32_or = w32_comb(bor) + +-- xor function that may receive a variable number of arguments +local function w32_xor_n(a, ...) + local aa, ab, ac, ad = w32_to_bytes(a) + for i = 1, select('#', ...) do + local ba, bb, bc, bd = w32_to_bytes(select(i, ...)) + aa, ab, ac, ad = bxor(aa, ba), bxor(ab, bb), bxor(ac, bc), bxor(ad, bd) + end + return bytes_to_w32(aa, ab, ac, ad) +end + +-- combining 3 32bit numbers through binary "or" operation +local function w32_or3(a, b, c) + local aa, ab, ac, ad = w32_to_bytes(a) + local ba, bb, bc, bd = w32_to_bytes(b) + local ca, cb, cc, cd = w32_to_bytes(c) + return bytes_to_w32( + bor(aa, bor(ba, ca)), bor(ab, bor(bb, cb)), bor(ac, bor(bc, cc)), bor(ad, bor(bd, cd)) + ) +end + +-- binary complement for 32bit numbers +local function w32_not(a) + return 4294967295 - (a % 4294967296) +end + +-- adding 2 32bit numbers, cutting off the remainder on 33th bit +local function w32_add(a, b) return (a + b) % 4294967296 end + +-- adding n 32bit numbers, cutting off the remainder (again) +local function w32_add_n(a, ...) + for i = 1, select('#', ...) do + a = (a + select(i, ...)) % 4294967296 + end + return a +end + +-- converting the number to a hexadecimal string +local function w32_to_hexstring(w) return format("%08x", w) end + +-- calculating the SHA1 for some text +function sha1.hex(msg) + local H0, H1, H2, H3, H4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 + local msg_len_in_bits = #msg * 8 + + local first_append = char(0x80) -- append a '1' bit plus seven '0' bits + + local non_zero_message_bytes = #msg + 1 + 8 -- the +1 is the appended bit 1, the +8 are for the final appended length + local current_mod = non_zero_message_bytes % 64 + local second_append = current_mod > 0 and rep(char(0), 64 - current_mod) or "" + + -- now to append the length as a 64-bit number. + local B1, R1 = modf(msg_len_in_bits / 0x01000000) + local B2, R2 = modf(0x01000000 * R1 / 0x00010000) + local B3, R3 = modf(0x00010000 * R2 / 0x00000100) + local B4 = 0x00000100 * R3 + + local L64 = char(0) .. char(0) .. char(0) .. char(0) -- high 32 bits + .. char(B1) .. char(B2) .. char(B3) .. char(B4) -- low 32 bits + + msg = msg .. first_append .. second_append .. L64 + + assert(#msg % 64 == 0) + + local chunks = #msg / 64 + + local W = {} + local start, A, B, C, D, E, f, K, TEMP + local chunk = 0 + + while chunk < chunks do + -- + -- break chunk up into W[0] through W[15] + -- + start, chunk = chunk * 64 + 1, chunk + 1 + + for t = 0, 15 do + W[t] = bytes_to_w32(msg:byte(start, start + 3)) + start = start + 4 + end + + -- + -- build W[16] through W[79] + -- + for t = 16, 79 do + -- For t = 16 to 79 let Wt = S1(Wt-3 XOR Wt-8 XOR Wt-14 XOR Wt-16). + W[t] = w32_rot(1, w32_xor_n(W[t - 3], W[t - 8], W[t - 14], W[t - 16])) + end + + A, B, C, D, E = H0, H1, H2, H3, H4 + + for t = 0, 79 do + if t <= 19 then + -- (B AND C) OR ((NOT B) AND D) + f = w32_or(w32_and(B, C), w32_and(w32_not(B), D)) + K = 0x5A827999 + elseif t <= 39 then + -- B XOR C XOR D + f = w32_xor_n(B, C, D) + K = 0x6ED9EBA1 + elseif t <= 59 then + -- (B AND C) OR (B AND D) OR (C AND D + f = w32_or3(w32_and(B, C), w32_and(B, D), w32_and(C, D)) + K = 0x8F1BBCDC + else + -- B XOR C XOR D + f = w32_xor_n(B, C, D) + K = 0xCA62C1D6 + end + + -- TEMP = S5(A) + ft(B,C,D) + E + Wt + Kt; + A, B, C, D, E = w32_add_n(w32_rot(5, A), f, E, W[t], K), + A, w32_rot(30, B), C, D + end + -- Let H0 = H0 + A, H1 = H1 + B, H2 = H2 + C, H3 = H3 + D, H4 = H4 + E. + H0, H1, H2, H3, H4 = w32_add(H0, A), w32_add(H1, B), w32_add(H2, C), w32_add(H3, D), w32_add(H4, E) + end + local f = w32_to_hexstring + return f(H0) .. f(H1) .. f(H2) .. f(H3) .. f(H4) +end + +local function hex_to_binary(hex) + return hex:gsub('..', function(hexval) + return string.char(tonumber(hexval, 16)) + end) +end + +function sha1.bin(msg) + return hex_to_binary(sha1.hex(msg)) +end + +local xor_with_0x5c = {} +local xor_with_0x36 = {} +-- building the lookuptables ahead of time (instead of littering the source code +-- with precalculated values) +for i = 0, 0xff do + xor_with_0x5c[char(i)] = char(bxor(i, 0x5c)) + xor_with_0x36[char(i)] = char(bxor(i, 0x36)) +end + +local blocksize = 64 -- 512 bits + +function sha1.hmacHex(key, text) + assert(type(key) == 'string', "key passed to hmacHex should be a string") + assert(type(text) == 'string', "text passed to hmacHex should be a string") + + if #key > blocksize then + key = sha1.bin(key) + end + + local key_xord_with_0x36 = key:gsub('.', xor_with_0x36) .. string.rep(string.char(0x36), blocksize - #key) + local key_xord_with_0x5c = key:gsub('.', xor_with_0x5c) .. string.rep(string.char(0x5c), blocksize - #key) + + return sha1.hex(key_xord_with_0x5c .. sha1.bin(key_xord_with_0x36 .. text)) +end + +function sha1.hmacBin(key, text) + return hex_to_binary(sha1.hmacHex(key, text)) +end + +return sha1 diff --git a/mac/.config/mpv/script-modules/user-input-module.lua b/mac/.config/mpv/script-modules/user-input-module.lua new file mode 100644 index 0000000..f15d5c4 --- /dev/null +++ b/mac/.config/mpv/script-modules/user-input-module.lua @@ -0,0 +1,126 @@ +--[[ + This is a module designed to interface with mpv-user-input + https://github.com/CogentRedTester/mpv-user-input + + Loading this script as a module will return a table with two functions to format + requests to get and cancel user-input requests. See the README for details. + + Alternatively, developers can just paste these functions directly into their script, + however this is not recommended as there is no guarantee that the formatting of + these requests will remain the same for future versions of user-input. +]] + +local API_VERSION = "0.1.0" + +local mp = require 'mp' +local msg = require "mp.msg" +local utils = require 'mp.utils' +local mod = {} + +local name = mp.get_script_name() +local counter = 1 + +local function pack(...) + local t = {...} + t.n = select("#", ...) + return t +end + +local request_mt = {} + +-- ensures the option tables are correctly formatted based on the input +local function format_options(options, response_string) + return { + response = response_string, + version = API_VERSION, + id = name..'/'..(options.id or ""), + source = name, + request_text = ("[%s] %s"):format(options.source or name, options.request_text or options.text or "requesting user input:"), + default_input = options.default_input, + cursor_pos = tonumber(options.cursor_pos), + queueable = options.queueable and true, + replace = options.replace and true + } +end + +-- cancels the request +function request_mt:cancel() + assert(self.uid, "request object missing UID") + mp.commandv("script-message-to", "user_input", "cancel-user-input/uid", self.uid) +end + +-- updates the options for the request +function request_mt:update(options) + assert(self.uid, "request object missing UID") + options = utils.format_json( format_options(options) ) + mp.commandv("script-message-to", "user_input", "update-user-input/uid", self.uid, options) +end + +-- sends a request to ask the user for input using formatted options provided +-- creates a script message to recieve the response and call fn +function mod.get_user_input(fn, options, ...) + options = options or {} + local response_string = name.."/__user_input_request/"..counter + counter = counter + 1 + + local request = { + uid = response_string, + passthrough_args = pack(...), + callback = fn, + pending = true + } + + -- create a callback for user-input to respond to + mp.register_script_message(response_string, function(response) + mp.unregister_script_message(response_string) + request.pending = false + + response = utils.parse_json(response) + request.callback(response.line, response.err, unpack(request.passthrough_args, 1, request.passthrough_args.n)) + end) + + -- send the input command + options = utils.format_json( format_options(options, response_string) ) + mp.commandv("script-message-to", "user_input", "request-user-input", options) + + return setmetatable(request, { __index = request_mt }) +end + +-- runs the request synchronously using coroutines +-- takes the option table and an optional coroutine resume function +function mod.get_user_input_co(options, co_resume) + local co, main = coroutine.running() + assert(not main and co, "get_user_input_co must be run from within a coroutine") + + local uid = {} + local request = mod.get_user_input(function(line, err) + if co_resume then + co_resume(uid, line, err) + else + local success, er = coroutine.resume(co, uid, line, err) + if not success then + msg.warn(debug.traceback(co)) + msg.error(er) + end + end + end, options) + + -- if the uid was not sent then the coroutine was resumed by the user. + -- we will treat this as a cancellation request + local success, line, err = coroutine.yield(request) + if success ~= uid then + request:cancel() + request.callback = function() end + return nil, "cancelled" + end + + return line, err +end + +-- sends a request to cancel all input requests with the given id +function mod.cancel_user_input(id) + id = name .. '/' .. (id or "") + mp.commandv("script-message-to", "user_input", "cancel-user-input/id", id) +end + +return mod
\ No newline at end of file diff --git a/mac/.config/mpv/script-modules/utf8/LICENSE b/mac/.config/mpv/script-modules/utf8/LICENSE new file mode 100644 index 0000000..fd3b301 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Stepets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mac/.config/mpv/script-modules/utf8/README.md b/mac/.config/mpv/script-modules/utf8/README.md new file mode 100644 index 0000000..0c31574 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/README.md @@ -0,0 +1,93 @@ +# utf8.lua +pure-lua 5.3 regex library for Lua 5.3, Lua 5.1, LuaJIT + +This library provides simple way to add UTF-8 support into your application. + +#### Example: +```Lua +local utf8 = require('.utf8'):init() +for k,v in pairs(utf8) do + string[k] = v +end + +local str = "пыщпыщ ололоо я водитель нло" +print(str:find("(.л.+)н")) +-- 8 26 ололоо я водитель + +print(str:gsub("ло+", "보라")) +-- пыщпыщ о보라보라 я водитель н보라 3 + +print(str:match("^п[лопыщ ]*я")) +-- пыщпыщ ололоо я +``` + +#### Usage: + +This library can be used as drop-in replacement for vanilla string library. It exports all vanilla functions under `raw` sub-object. + +```Lua +local utf8 = require('.utf8'):init() +local str = "пыщпыщ ололоо я водитель нло" +utf8.gsub(str, "ло+", "보라") +-- пыщпыщ о보라보라 я водитель н보라 3 +utf8.raw.gsub(str, "ло+", "보라") +-- пыщпыщ о보라보라о я водитель н보라 3 +``` + +It also provides all functions from Lua 5.3 UTF-8 [module](https://www.lua.org/manual/5.3/manual.html#6.5) except `utf8.len (s [, i [, j]])`. If you need to validate your strings use `utf8.validate(str, byte_pos)` or iterate over with `utf8.validator`. + +Please note that library assumes regexes are valid UTF-8 strings, if you need to manipulate individual bytes use vanilla functions under `utf8.raw`. + + +#### Installation: + +Download repository to your project folder. (no rockspecs yet) + +Examples assume library placed under `utf8` subfolder not `utf8.lua`. + +As of Lua 5.3 default `utf8` module has precedence over user-provided. In this case you can specify full module path (`.utf8`). + +#### Configuration: + +Library is highly modular. You can provide your implementation for almost any function used. Library already has several back-ends: +- [Runtime character class processing](charclass/runtime/init.lua) using hardcoded codepoint ranges or using native functions through `ffi`. +- [Basic functions](primitives/init.lua) for working with UTF-8 characters have specializations for `ffi`-enabled runtime and for tarantool. + +Probably most interesting [customizations](init.lua) are `utf8.config.loadstring` and `utf8.config.cache` if you want to precompile your regexes. + +```Lua +local utf8 = require('.utf8') +utf8.config = { + cache = my_smart_cache, +} +utf8:init() +``` + +For `lower` and `upper` functions to work in environments where `ffi` cannot be used, you can specify substitution tables ([data example](https://github.com/artemshein/luv/blob/master/utf8data.lua)) + +```Lua +local utf8 = require('.utf8') +utf8.config = { + conversion = { + uc_lc = utf8_uc_lc, + lc_uc = utf8_lc_uc + }, +} +utf8:init() +``` +Customization is done before initialization. If you want, you can change configuration after `init`, it might work for everything but modules. All of them should be reloaded. + +#### [Documentation:](test/test.lua) + +#### Issue reporting: + +Please provide example script that causes error together with environment description and debug output. Debug output can be obtained like: +```Lua +local utf8 = require('.utf8') +utf8.config = { + debug = utf8:require("util").debug +} +utf8:init() +-- your code +``` +Default logger used is [`io.write`](https://www.lua.org/manual/5.3/manual.html#pdf-io.write) and can be changed by specifying `logger = my_logger` in configuration diff --git a/mac/.config/mpv/script-modules/utf8/begins/compiletime/parser.lua b/mac/.config/mpv/script-modules/utf8/begins/compiletime/parser.lua new file mode 100644 index 0000000..c54c0df --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/begins/compiletime/parser.lua @@ -0,0 +1,17 @@ +return function(utf8) + +utf8.config.begins = utf8.config.begins or { + utf8:require "begins.compiletime.vanilla" +} + +function utf8.regex.compiletime.begins.parse(regex, c, bs, ctx) + for _, m in ipairs(utf8.config.begins) do + local functions, move = m.parse(regex, c, bs, ctx) + utf8.debug("begins", _, c, bs, move, functions) + if functions then + return functions, move + end + end +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/begins/compiletime/vanilla.lua b/mac/.config/mpv/script-modules/utf8/begins/compiletime/vanilla.lua new file mode 100644 index 0000000..bcafa17 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/begins/compiletime/vanilla.lua @@ -0,0 +1,60 @@ +return function(utf8) + +local matchers = { + sliding = function() + return [[ + add(function(ctx) -- sliding + while ctx.pos <= ctx.len do + local clone = ctx:clone() + -- debug('starting from', clone, "start_pos", clone.pos) + clone.result.start = clone.pos + clone:next_function() + clone:get_function()(clone) + + ctx:next_char() + end + ctx:terminate() + end) +]] + end, + fromstart = function(ctx) + return [[ + add(function(ctx) -- fromstart + if ctx.byte_pos > ctx.len then + return + end + ctx.result.start = ctx.pos + ctx:next_function() + ctx:get_function()(ctx) + ctx:terminate() + end) +]] + end, +} + +local function default() + return matchers.sliding() +end + +local function parse(regex, c, bs, ctx) + if bs ~= 1 then return end + + local functions + local skip = 0 + + if c == '^' then + functions = matchers.fromstart() + skip = 1 + else + functions = matchers.sliding() + end + + return functions, skip +end + +return { + parse = parse, + default = default, +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/compiletime/builder.lua b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/builder.lua new file mode 100644 index 0000000..9d9c603 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/builder.lua @@ -0,0 +1,128 @@ +return function(utf8) + +local byte = utf8.byte +local unpack = utf8.config.unpack + +local builder = {} +local mt = {__index = builder} + +utf8.regex.compiletime.charclass.builder = builder + +function builder.new() + return setmetatable({}, mt) +end + +function builder:invert() + self.inverted = true + return self +end + +function builder:internal() -- is it enclosed in [] + self.internal = true + return self +end + +function builder:with_codes(...) + local codes = {...} + self.codes = self.codes or {} + + for _, v in ipairs(codes) do + table.insert(self.codes, type(v) == "number" and v or byte(v)) + end + + table.sort(self.codes) + return self +end + +function builder:with_ranges(...) + local ranges = {...} + self.ranges = self.ranges or {} + + for _, v in ipairs(ranges) do + table.insert(self.ranges, v) + end + + return self +end + +function builder:with_classes(...) + local classes = {...} + self.classes = self.classes or {} + + for _, v in ipairs(classes) do + table.insert(self.classes, v) + end + + return self +end + +function builder:without_classes(...) + local not_classes = {...} + self.not_classes = self.not_classes or {} + + for _, v in ipairs(not_classes) do + table.insert(self.not_classes, v) + end + + return self +end + +function builder:include(b) + if not b.inverted then + if b.codes then + self:with_codes(unpack(b.codes)) + end + if b.ranges then + self:with_ranges(unpack(b.ranges)) + end + if b.classes then + self:with_classes(unpack(b.classes)) + end + if b.not_classes then + self:without_classes(unpack(b.not_classes)) + end + else + self.includes = self.includes or {} + self.includes[#self.includes + 1] = b + end + return self +end + +function builder:build() + if self.codes and #self.codes == 1 and not self.inverted and not self.ranges and not self.classes and not self.not_classes and not self.includes then + return "{test = function(self, cc) return cc == " .. self.codes[1] .. " end}" + else + local codes_list = table.concat(self.codes or {}, ', ') + local ranges_list = '' + for i, r in ipairs(self.ranges or {}) do ranges_list = ranges_list .. (i > 1 and ', {' or '{') .. tostring(r[1]) .. ', ' .. tostring(r[2]) .. '}' end + local classes_list = '' + if self.classes then classes_list = "'" .. table.concat(self.classes, "', '") .. "'" end + local not_classes_list = '' + if self.not_classes then not_classes_list = "'" .. table.concat(self.not_classes, "', '") .. "'" end + + local subs_list = '' + for i, r in ipairs(self.includes or {}) do subs_list = subs_list .. (i > 1 and ', ' or '') .. r:build() .. '' end + + local src = [[cl.new():with_codes( + ]] .. codes_list .. [[ + ):with_ranges( + ]] .. ranges_list .. [[ + ):with_classes( + ]] .. classes_list .. [[ + ):without_classes( + ]] .. not_classes_list .. [[ + ):with_subs( + ]] .. subs_list .. [[ + )]] + + if self.inverted then + src = src .. ':invert()' + end + + return src + end +end + +return builder + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/compiletime/parser.lua b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/parser.lua new file mode 100644 index 0000000..4f1d4a9 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/parser.lua @@ -0,0 +1,21 @@ +return function(utf8) + +utf8.config.compiletime_charclasses = utf8.config.compiletime_charclasses or { + utf8:require "charclass.compiletime.vanilla", + utf8:require "charclass.compiletime.range", + utf8:require "charclass.compiletime.stub", +} + +function utf8.regex.compiletime.charclass.parse(regex, c, bs, ctx) + utf8.debug("parse charclass():", regex, c, bs, regex[bs]) + for _, p in ipairs(utf8.config.compiletime_charclasses) do + local charclass, nbs = p(regex, c, bs, ctx) + if charclass then + ctx.prev_class = charclass:build() + utf8.debug("cc", ctx.prev_class, _, c, bs, nbs) + return charclass, nbs + end + end +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/compiletime/range.lua b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/range.lua new file mode 100644 index 0000000..2996234 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/range.lua @@ -0,0 +1,44 @@ +return function(utf8) + +local cl = utf8.regex.compiletime.charclass.builder + +local next = utf8.util.next + +return function(str, c, bs, ctx) + if not ctx.internal then return end + + local nbs = bs + + local r1, r2 + + local c, nbs = c, bs + if c == '%' then + c, nbs = next(str, nbs) + r1 = c + else + r1 = c + end + + utf8.debug("range r1", r1, nbs) + + c, nbs = next(str, nbs) + if c ~= '-' then return end + + c, nbs = next(str, nbs) + if c == '%' then + c, nbs = next(str, nbs) + r2 = c + elseif c ~= '' and c ~= ']' then + r2 = c + end + + utf8.debug("range r2", r2, nbs) + + if r1 and r2 then + return cl.new():with_ranges{utf8.byte(r1), utf8.byte(r2)}, utf8.next(str, nbs) - bs + else + return + end +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/compiletime/stub.lua b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/stub.lua new file mode 100644 index 0000000..395d05c --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/stub.lua @@ -0,0 +1,9 @@ +return function(utf8) + +local cl = utf8.regex.compiletime.charclass.builder + +return function(str, c, bs, ctx) + return cl.new():with_codes(c), utf8.next(str, bs) - bs +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/compiletime/vanilla.lua b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/vanilla.lua new file mode 100644 index 0000000..8e7f0b3 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/compiletime/vanilla.lua @@ -0,0 +1,131 @@ +return function(utf8) + +local cl = utf8:require "charclass.compiletime.builder" + +local next = utf8.util.next + +local token = 1 + +local function parse(str, c, bs, ctx) + local tttt = token + token = token + 1 + + local class + local nbs = bs + utf8.debug("cc_parse", tttt, str, c, nbs, next(str, nbs)) + + if c == '%' then + c, nbs = next(str, bs) + if c == '' then + error("malformed pattern (ends with '%')") + end + local _c = utf8.raw.lower(c) + local matched + if _c == 'a' then + matched = ('alpha') + elseif _c == 'c' then + matched = ('cntrl') + elseif _c == 'd' then + matched = ('digit') + elseif _c == 'g' then + matched = ('graph') + elseif _c == 'l' then + matched = ('lower') + elseif _c == 'p' then + matched = ('punct') + elseif _c == 's' then + matched = ('space') + elseif _c == 'u' then + matched = ('upper') + elseif _c == 'w' then + matched = ('alnum') + elseif _c == 'x' then + matched = ('xdigit') + end + + if matched then + if _c ~= c then + class = cl.new():without_classes(matched) + else + class = cl.new():with_classes(matched) + end + elseif _c == 'z' then + class = cl.new():with_codes(0) + if _c ~= c then + class = class:invert() + end + else + class = cl.new():with_codes(c) + end + elseif c == '[' and not ctx.internal then + local old_internal = ctx.internal + ctx.internal = true + class = cl.new() + local firstletter = true + while true do + local prev_nbs = nbs + c, nbs = next(str, nbs) + utf8.debug("next", tttt, c, nbs) + if c == '^' and firstletter then + class:invert() + local nc, nnbs = next(str, nbs) + if nc == ']' then + class:with_codes(nc) + nbs = nnbs + end + elseif c == ']' then + if firstletter then + class:with_codes(c) + else + utf8.debug('] on pos', tttt, nbs) + break + end + elseif c == '' then + error "malformed pattern (missing ']')" + else + local sub_class, skip = utf8.regex.compiletime.charclass.parse(str, c, nbs, ctx) + nbs = prev_nbs + skip + utf8.debug("include", tttt, bs, prev_nbs, nbs, skip) + class:include(sub_class) + end + firstletter = false + end + ctx.internal = old_internal + elseif c == '.' then + if not ctx.internal then + class = cl.new():invert() + else + class = cl.new():with_codes(c) + end + end + + return class, utf8.next(str, nbs) - bs +end + +return parse + +end + +--[[ + x: (where x is not one of the magic characters ^$()%.[]*+-?) represents the character x itself. + .: (a dot) represents all characters. + %a: represents all letters. + %c: represents all control characters. + %d: represents all digits. + %g: represents all printable characters except space. + %l: represents all lowercase letters. + %p: represents all punctuation characters. + %s: represents all space characters. + %u: represents all uppercase letters. + %w: represents all alphanumeric characters. + %x: represents all hexadecimal digits. + %x: (where x is any non-alphanumeric character) represents the character x. This is the standard way to escape the magic characters. Any non-alphanumeric character (including all punctuation characters, even the non-magical) can be preceded by a '%' when used to represent itself in a pattern. + [set]: represents the class which is the union of all characters in set. A range of characters can be specified by separating the end characters of the range, in ascending order, with a '-'. All classes %x described above can also be used as components in set. All other characters in set represent themselves. For example, [%w_] (or [_%w]) represents all alphanumeric characters plus the underscore, [0-7] represents the octal digits, and [0-7%l%-] represents the octal digits plus the lowercase letters plus the '-' character. + + You can put a closing square bracket in a set by positioning it as the first character in the set. You can put a hyphen in a set by positioning it as the first or the last character in the set. (You can also use an escape for both cases.) + + The interaction between ranges and classes is not defined. Therefore, patterns like [%a-z] or [a-%%] have no meaning. + [^set]: represents the complement of set, where set is interpreted as above. + +For all classes represented by single letters (%a, %c, etc.), the corresponding uppercase letter represents the complement of the class. For instance, %S represents all non-space characters. +]] diff --git a/mac/.config/mpv/script-modules/utf8/charclass/runtime/base.lua b/mac/.config/mpv/script-modules/utf8/charclass/runtime/base.lua new file mode 100644 index 0000000..33d7713 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/runtime/base.lua @@ -0,0 +1,184 @@ +return function(utf8) + +local class = {} +local mt = {__index = class} + +local utf8gensub = utf8.gensub + +function class.new() + return setmetatable({}, mt) +end + +function class:invert() + self.inverted = true + return self +end + +function class:with_codes(...) + local codes = {...} + self.codes = self.codes or {} + + for _, v in ipairs(codes) do + table.insert(self.codes, v) + end + + table.sort(self.codes) + return self +end + +function class:with_ranges(...) + local ranges = {...} + self.ranges = self.ranges or {} + + for _, v in ipairs(ranges) do + table.insert(self.ranges, v) + end + + return self +end + +function class:with_classes(...) + local classes = {...} + self.classes = self.classes or {} + + for _, v in ipairs(classes) do + table.insert(self.classes, v) + end + + return self +end + +function class:without_classes(...) + local not_classes = {...} + self.not_classes = self.not_classes or {} + + for _, v in ipairs(not_classes) do + table.insert(self.not_classes, v) + end + + return self +end + +function class:with_subs(...) + local subs = {...} + self.subs = self.subs or {} + + for _, v in ipairs(subs) do + table.insert(self.subs, v) + end + + return self +end + +function class:in_codes(item) + if not self.codes or #self.codes == 0 then return nil end + + local head, tail = 1, #self.codes + local mid = math.floor((head + tail)/2) + while (tail - head) > 1 do + if self.codes[mid] > item then + tail = mid + else + head = mid + end + mid = math.floor((head + tail)/2) + end + if self.codes[head] == item then + return true, head + elseif self.codes[tail] == item then + return true, tail + else + return false + end +end + +function class:in_ranges(char_code) + if not self.ranges or #self.ranges == 0 then return nil end + + for _,r in ipairs(self.ranges) do + if r[1] <= char_code and char_code <= r[2] then + return true + end + end + return false +end + +function class:in_classes(char_code) + if not self.classes or #self.classes == 0 then return nil end + + for _, class in ipairs(self.classes) do + if self:is(class, char_code) then + return true + end + end + return false +end + +function class:in_not_classes(char_code) + if not self.not_classes or #self.not_classes == 0 then return nil end + + for _, class in ipairs(self.not_classes) do + if self:is(class, char_code) then + return true + end + end + return false +end + +function class:is(class, char_code) + error("not implemented") +end + +function class:in_subs(char_code) + if not self.subs or #self.subs == 0 then return nil end + + for _, c in ipairs(self.subs) do + if not c:test(char_code) then + return false + end + end + return true +end + +function class:test(char_code) + local result = self:do_test(char_code) + -- utf8.debug('class:test', result, "'" .. (char_code and utf8.char(char_code) or 'nil') .. "'", char_code) + return result +end + +function class:do_test(char_code) + if not char_code then return false end + local in_not_classes = self:in_not_classes(char_code) + if in_not_classes then + return not not self.inverted + end + local in_codes = self:in_codes(char_code) + if in_codes then + return not self.inverted + end + local in_ranges = self:in_ranges(char_code) + if in_ranges then + return not self.inverted + end + local in_classes = self:in_classes(char_code) + if in_classes then + return not self.inverted + end + local in_subs = self:in_subs(char_code) + if in_subs then + return not self.inverted + end + if (in_codes == nil) + and (in_ranges == nil) + and (in_classes == nil) + and (in_subs == nil) + and (in_not_classes == false) then + return not self.inverted + else + return not not self.inverted + end +end + +return class + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/runtime/dummy.lua b/mac/.config/mpv/script-modules/utf8/charclass/runtime/dummy.lua new file mode 100644 index 0000000..1faddc1 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/runtime/dummy.lua @@ -0,0 +1,41 @@ +return function(utf8) + +local base = utf8:require "charclass.runtime.base" + +local dummy = setmetatable({}, {__index = base}) +local mt = {__index = dummy} + +function dummy.new() + return setmetatable({}, mt) +end + +function dummy:with_classes(...) + local classes = {...} + for _, c in ipairs(classes) do + if c == 'alpha' then self:with_ranges({65, 90}, {97, 122}) + elseif c == 'cntrl' then self:with_ranges({0, 31}):with_codes(127) + elseif c == 'digit' then self:with_ranges({48, 57}) + elseif c == 'graph' then self:with_ranges({1, 8}, {14, 31}, {33, 132}, {134, 159}, {161, 5759}, {5761, 8191}, {8203, 8231}, {8234, 8238}, {8240, 8286}, {8288, 12287}) + elseif c == 'lower' then self:with_ranges({97, 122}) + elseif c == 'punct' then self:with_ranges({33, 47}, {58, 64}, {91, 96}, {123, 126}) + elseif c == 'space' then self:with_ranges({9, 13}):with_codes(32, 133, 160, 5760):with_ranges({8192, 8202}):with_codes(8232, 8233, 8239, 8287, 12288) + elseif c == 'upper' then self:with_ranges({65, 90}) + elseif c == 'alnum' then self:with_ranges({48, 57}, {65, 90}, {97, 122}) + elseif c == 'xdigit' then self:with_ranges({48, 57}, {65, 70}, {97, 102}) + end + end + return self +end + +function dummy:without_classes(...) + local classes = {...} + if #classes > 0 then + return self:with_subs(dummy.new():with_classes(...):invert()) + else + return self + end +end + +return dummy + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/runtime/init.lua b/mac/.config/mpv/script-modules/utf8/charclass/runtime/init.lua new file mode 100644 index 0000000..e71d037 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/runtime/init.lua @@ -0,0 +1,22 @@ +return function(utf8) + +local provided = utf8.config.runtime_charclasses + +if provided then + if type(provided) == "table" then + return provided + elseif type(provided) == "function" then + return provided(utf8) + else + return utf8:require(provided) + end +end + +local ffi = pcall(require, "ffi") +if not ffi then + return utf8:require "charclass.runtime.dummy" +else + return utf8:require "charclass.runtime.native" +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/charclass/runtime/native.lua b/mac/.config/mpv/script-modules/utf8/charclass/runtime/native.lua new file mode 100644 index 0000000..f7b7890 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/charclass/runtime/native.lua @@ -0,0 +1,47 @@ +return function(utf8) + +os.setlocale(utf8.config.locale, "ctype") + +local ffi = require("ffi") +ffi.cdef[[ + int iswalnum(int c); + int iswalpha(int c); + int iswascii(int c); + int iswblank(int c); + int iswcntrl(int c); + int iswdigit(int c); + int iswgraph(int c); + int iswlower(int c); + int iswprint(int c); + int iswpunct(int c); + int iswspace(int c); + int iswupper(int c); + int iswxdigit(int c); +]] + +local base = utf8:require "charclass.runtime.base" + +local native = setmetatable({}, {__index = base}) +local mt = {__index = native} + +function native.new() + return setmetatable({}, mt) +end + +function native:is(class, char_code) + if class == 'alpha' then return ffi.C.iswalpha(char_code) ~= 0 + elseif class == 'cntrl' then return ffi.C.iswcntrl(char_code) ~= 0 + elseif class == 'digit' then return ffi.C.iswdigit(char_code) ~= 0 + elseif class == 'graph' then return ffi.C.iswgraph(char_code) ~= 0 + elseif class == 'lower' then return ffi.C.iswlower(char_code) ~= 0 + elseif class == 'punct' then return ffi.C.iswpunct(char_code) ~= 0 + elseif class == 'space' then return ffi.C.iswspace(char_code) ~= 0 + elseif class == 'upper' then return ffi.C.iswupper(char_code) ~= 0 + elseif class == 'alnum' then return ffi.C.iswalnum(char_code) ~= 0 + elseif class == 'xdigit' then return ffi.C.iswxdigit(char_code) ~= 0 + end +end + +return native + +end diff --git a/mac/.config/mpv/script-modules/utf8/context/compiletime.lua b/mac/.config/mpv/script-modules/utf8/context/compiletime.lua new file mode 100644 index 0000000..621204d --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/context/compiletime.lua @@ -0,0 +1,18 @@ +return function(utf8) + +local begins = utf8.config.begins +local ends = utf8.config.ends + +return { + new = function() + return { + prev_class = nil, + begins = begins[1].default(), + ends = ends[1].default(), + funcs = {}, + internal = false, -- hack for ranges, flags if parser is in [] + } + end +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/context/runtime.lua b/mac/.config/mpv/script-modules/utf8/context/runtime.lua new file mode 100644 index 0000000..6fb024c --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/context/runtime.lua @@ -0,0 +1,112 @@ +return function(utf8) + +local utf8unicode = utf8.unicode +local utf8sub = utf8.sub +local sub = utf8.raw.sub +local byte = utf8.raw.byte +local utf8len = utf8.len +local utf8next = utf8.next +local rawgsub = utf8.raw.gsub +local utf8offset = utf8.offset +local utf8char = utf8.char + +local util = utf8.util + +local ctx = {} +local mt = { + __index = ctx, + __tostring = function(self) + return rawgsub([[str: '${str}', char: ${pos} '${char}', func: ${func_pos}]], "${(.-)}", { + str = self.str, + pos = self.pos, + char = self:get_char(), + func_pos = self.func_pos, + }) + end +} + +function ctx.new(obj) + obj = obj or {} + local res = setmetatable({ + pos = obj.pos or 1, + byte_pos = obj.pos or 1, + str = assert(obj.str, "str is required"), + len = obj.len, + rawlen = obj.rawlen, + bytes = obj.bytes, + offsets = obj.offsets, + starts = obj.starts or nil, + functions = obj.functions or {}, + func_pos = obj.func_pos or 1, + ends = obj.ends or nil, + result = obj.result and util.copy(obj.result) or {}, + captures = obj.captures and util.copy(obj.captures, true) or {active = {}}, + modified = false, + }, mt) + if not res.bytes then + local str = res.str + local l = #str + local bytes = utf8.config.int32array(l) + local offsets = utf8.config.int32array(l) + local c, bs, i = nil, 1, 1 + while bs <= l do + bytes[i] = utf8unicode(str, bs, bs) + offsets[i] = bs + bs = utf8.next(str, bs) + i = i + 1 + end + res.bytes = bytes + res.offsets = offsets + res.byte_pos = res.pos + res.len = i + res.rawlen = l + end + + return res +end + +function ctx:clone() + return self:new() +end + +function ctx:next_char() + self.pos = self.pos + 1 + self.byte_pos = self.pos +end + +function ctx:prev_char() + self.pos = self.pos - 1 + self.byte_pos = self.pos +end + +function ctx:get_char() + if self.len <= self.pos then return "" end + return utf8char(self.bytes[self.pos]) +end + +function ctx:get_charcode() + if self.len <= self.pos then return nil end + return self.bytes[self.pos] +end + +function ctx:next_function() + self.func_pos = self.func_pos + 1 +end + +function ctx:get_function() + return self.functions[self.func_pos] +end + +function ctx:done() + utf8.debug('done', self) + coroutine.yield(self, self.result, self.captures) +end + +function ctx:terminate() + utf8.debug('terminate', self) + coroutine.yield(nil) +end + +return ctx + +end diff --git a/mac/.config/mpv/script-modules/utf8/ends/compiletime/parser.lua b/mac/.config/mpv/script-modules/utf8/ends/compiletime/parser.lua new file mode 100644 index 0000000..f966e94 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/ends/compiletime/parser.lua @@ -0,0 +1,17 @@ +return function(utf8) + +utf8.config.ends = utf8.config.ends or { + utf8:require "ends.compiletime.vanilla" +} + +function utf8.regex.compiletime.ends.parse(regex, c, bs, ctx) + for _, m in ipairs(utf8.config.ends) do + local functions, move = m.parse(regex, c, bs, ctx) + utf8.debug("ends", _, c, bs, move, functions) + if functions then + return functions, move + end + end +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/ends/compiletime/vanilla.lua b/mac/.config/mpv/script-modules/utf8/ends/compiletime/vanilla.lua new file mode 100644 index 0000000..5fe7eb3 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/ends/compiletime/vanilla.lua @@ -0,0 +1,46 @@ +return function(utf8) + +local matchers = { + any = function() + return [[ + add(function(ctx) -- any + ctx.result.finish = ctx.pos - 1 + ctx:done() + end) +]] + end, + toend = function(ctx) + return [[ + add(function(ctx) -- toend + ctx.result.finish = ctx.pos - 1 + ctx.modified = true + if ctx.pos == utf8len(ctx.str) + 1 then ctx:done() end + end) +]] + end, +} + +local len = utf8.raw.len + +local function default() + return matchers.any() +end + +local function parse(regex, c, bs, ctx) + local functions + local skip = 0 + + if bs == len(regex) and c == '$' then + functions = matchers.toend() + skip = 1 + end + + return functions, skip +end + +return { + parse = parse, + default = default, +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/functions/lua53.lua b/mac/.config/mpv/script-modules/utf8/functions/lua53.lua new file mode 100644 index 0000000..26e6f23 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/functions/lua53.lua @@ -0,0 +1,152 @@ +return function(utf8) + +local utf8sub = utf8.sub +local utf8gensub = utf8.gensub +local unpack = utf8.config.unpack +local generate_matcher_function = utf8:require 'regex_parser' + +local +function get_matcher_function(regex, plain) + local res + if utf8.config.cache then + res = utf8.config.cache[plain and "plain" or "regex"][regex] + end + if res then + return res + end + res = generate_matcher_function(regex, plain) + if utf8.config.cache then + utf8.config.cache[plain and "plain" or "regex"][regex] = res + end + return res +end + +local function utf8find(str, regex, init, plain) + local func = get_matcher_function(regex, plain) + init = ((init or 1) < 0) and (utf8.len(str) + init + 1) or init + local ctx, result, captures = func(str, init, utf8) + if not ctx then return nil end + + utf8.debug('ctx:', ctx) + utf8.debug('result:', result) + utf8.debug('captures:', captures) + + return result.start, result.finish, unpack(captures) +end + +local function utf8match(str, regex, init) + local func = get_matcher_function(regex, false) + init = ((init or 1) < 0) and (utf8.len(str) + init + 1) or init + local ctx, result, captures = func(str, init, utf8) + if not ctx then return nil end + + utf8.debug('ctx:', ctx) + utf8.debug('result:', result) + utf8.debug('captures:', captures) + + if #captures > 0 then return unpack(captures) end + + return utf8sub(str, result.start, result.finish) +end + +local function utf8gmatch(str, regex) + regex = (utf8sub(regex,1,1) ~= '^') and regex or '%' .. regex + local func = get_matcher_function(regex, false) + local ctx, result, captures + local continue_pos = 1 + + return function() + ctx, result, captures = func(str, continue_pos, utf8) + + if not ctx then return nil end + + utf8.debug('ctx:', ctx) + utf8.debug('result:', result) + utf8.debug('captures:', captures) + + continue_pos = math.max(result.finish + 1, result.start + 1) + if #captures > 0 then + return unpack(captures) + else + return utf8sub(str, result.start, result.finish) + end + end +end + +local function replace(repl, args) + local ret = '' + if type(repl) == 'string' then + local ignore = false + local num + for _, c in utf8gensub(repl) do + if not ignore then + if c == '%' then + ignore = true + else + ret = ret .. c + end + else + num = tonumber(c) + if num then + ret = ret .. assert(args[num], "invalid capture index %" .. c) + else + ret = ret .. c + end + ignore = false + end + end + elseif type(repl) == 'table' then + ret = repl[args[1]] or args[0] + elseif type(repl) == 'function' then + ret = repl(unpack(args, 1)) or args[0] + end + return ret +end + +local function utf8gsub(str, regex, repl, limit) + limit = limit or -1 + local subbed = '' + local prev_sub_finish = 1 + + local func = get_matcher_function(regex, false) + local ctx, result, captures + local continue_pos = 1 + + local n = 0 + while limit ~= n do + ctx, result, captures = func(str, continue_pos, utf8) + if not ctx then break end + + utf8.debug('ctx:', ctx) + utf8.debug('result:', result) + utf8.debug('result:', utf8sub(str, result.start, result.finish)) + utf8.debug('captures:', captures) + + continue_pos = math.max(result.finish + 1, result.start + 1) + local args + if #captures > 0 then + args = {[0] = utf8sub(str, result.start, result.finish), unpack(captures)} + else + args = {[0] = utf8sub(str, result.start, result.finish)} + args[1] = args[0] + end + + subbed = subbed .. utf8sub(str, prev_sub_finish, result.start - 1) + subbed = subbed .. replace(repl, args) + prev_sub_finish = result.finish + 1 + n = n + 1 + + end + + return subbed .. utf8sub(str, prev_sub_finish), n +end + +-- attaching high-level functions +utf8.find = utf8find +utf8.match = utf8match +utf8.gmatch = utf8gmatch +utf8.gsub = utf8gsub + +return utf8 + +end diff --git a/mac/.config/mpv/script-modules/utf8/init.lua b/mac/.config/mpv/script-modules/utf8/init.lua new file mode 100644 index 0000000..d2f72a4 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/init.lua @@ -0,0 +1,71 @@ +local module_path = ... +module_path = module_path:match("^(.-)init$") or (module_path .. '.') + +local ffi_enabled, ffi = pcall(require, 'ffi') + +local utf8 = { + config = {}, + default = { + debug = nil, + logger = io.write, + loadstring = (loadstring or load), + unpack = (unpack or table.unpack), + cache = { + regex = setmetatable({},{ + __mode = 'kv' + }), + plain = setmetatable({},{ + __mode = 'kv' + }), + }, + locale = nil, + int32array = function(size) + if ffi_enabled then + return ffi.new("uint32_t[?]", size + 1) + else + return {} + end + end, + conversion = { + uc_lc = nil, + lc_uc = nil + } + }, + regex = { + compiletime = { + charclass = {}, + begins = {}, + ends = {}, + modifier = {}, + } + }, + util = {}, +} + +function utf8:require(name) + local full_module_path = module_path .. name + if package.loaded[full_module_path] then + return package.loaded[full_module_path] + end + + local mod = require(full_module_path) + if type(mod) == 'function' then + mod = mod(self) + package.loaded[full_module_path] = mod + end + return mod +end + +function utf8:init() + for k, v in pairs(self.default) do + self.config[k] = self.config[k] or v + end + + self:require "util" + self:require "primitives.init" + self:require "functions.lua53" + + return self +end + +return utf8 diff --git a/mac/.config/mpv/script-modules/utf8/modifier/compiletime/frontier.lua b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/frontier.lua new file mode 100644 index 0000000..cf0f4ab --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/frontier.lua @@ -0,0 +1,50 @@ +return function(utf8) + +local matchers = { + frontier = function(class, name) + local class_name = 'class' .. name + return [[ + local ]] .. class_name .. [[ = ]] .. class .. [[ + + add(function(ctx) -- frontier + ctx:prev_char() + local prev_charcode = ctx:get_charcode() or 0 + ctx:next_char() + local charcode = ctx:get_charcode() or 0 + -- debug("frontier pos", ctx.pos, "prev_charcode", prev_charcode, "charcode", charcode) + if ]] .. class_name .. [[:test(prev_charcode) then return end + if ]] .. class_name .. [[:test(charcode) then + ctx:next_function() + return ctx:get_function()(ctx) + end + end) +]] + end, + simple = utf8:require("modifier.compiletime.simple").simple, +} + +local function parse(regex, c, bs, ctx) + local functions, nbs, class + + if c == '%' then + if utf8.raw.sub(regex, bs + 1, bs + 1) ~= 'f' then return end + if utf8.raw.sub(regex, bs + 2, bs + 2) ~= '[' then error("missing '[' after '%f' in pattern") end + + functions = {} + if ctx.prev_class then + table.insert(functions, matchers.simple(ctx.prev_class, tostring(bs))) + ctx.prev_class = nil + end + class, nbs = utf8.regex.compiletime.charclass.parse(regex, '[', bs + 2, ctx) + nbs = nbs + 2 + table.insert(functions, matchers.frontier(class:build(), tostring(bs))) + end + + return functions, nbs +end + +return { + parse = parse, +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/modifier/compiletime/parser.lua b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/parser.lua new file mode 100644 index 0000000..9149f71 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/parser.lua @@ -0,0 +1,20 @@ +return function(utf8) + +utf8.config.modifier = utf8.config.modifier or { + utf8:require "modifier.compiletime.vanilla", + utf8:require "modifier.compiletime.frontier", + utf8:require "modifier.compiletime.stub", +} + +function utf8.regex.compiletime.modifier.parse(regex, c, bs, ctx) + for _, m in ipairs(utf8.config.modifier) do + local functions, move = m.parse(regex, c, bs, ctx) + utf8.debug("mod", _, c, bs, move, functions and utf8.config.unpack(functions)) + if functions then + ctx.prev_class = nil + return functions, move + end + end +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/modifier/compiletime/simple.lua b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/simple.lua new file mode 100644 index 0000000..1a28b85 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/simple.lua @@ -0,0 +1,23 @@ +return function(utf8) + +local matchers = { + simple = function(class, name) + local class_name = 'class' .. name + return [[ + local ]] .. class_name .. [[ = ]] .. class .. [[ + + add(function(ctx) -- simple + -- debug(ctx, 'simple', ']] .. class_name .. [[') + if ]] .. class_name .. [[:test(ctx:get_charcode()) then + ctx:next_char() + ctx:next_function() + return ctx:get_function()(ctx) + end + end) +]] + end, +} + +return matchers + +end diff --git a/mac/.config/mpv/script-modules/utf8/modifier/compiletime/stub.lua b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/stub.lua new file mode 100644 index 0000000..e1289a6 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/stub.lua @@ -0,0 +1,28 @@ +return function(utf8) + +local matchers = utf8:require("modifier.compiletime.simple") + +local function parse(regex, c, bs, ctx) + local functions + + if ctx.prev_class then + functions = { matchers.simple(ctx.prev_class, tostring(bs)) } + ctx.prev_class = nil + end + + return functions, 0 +end + +local function check(ctx) + if ctx.prev_class then + table.insert(ctx.funcs, matchers.simple(ctx.prev_class, tostring(ctx.pos))) + ctx.prev_class = nil + end +end + +return { + parse = parse, + check = check, +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/modifier/compiletime/vanilla.lua b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/vanilla.lua new file mode 100644 index 0000000..96e79d2 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/modifier/compiletime/vanilla.lua @@ -0,0 +1,270 @@ +return function(utf8) + +local utf8unicode = utf8.byte +local sub = utf8.raw.sub + +local matchers = { + star = function(class, name) + local class_name = 'class' .. name + return [[ + local ]] .. class_name .. [[ = ]] .. class .. [[ + + add(function(ctx) -- star + -- debug(ctx, 'star', ']] .. class_name .. [[') + local clone = ctx:clone() + while ]] .. class_name .. [[:test(clone:get_charcode()) do + clone:next_char() + end + local pos = clone.pos + while pos >= ctx.pos do + clone.pos = pos + clone.func_pos = ctx.func_pos + clone:next_function() + clone:get_function()(clone) + if clone.modified then + clone = ctx:clone() + end + pos = pos - 1 + end + end) +]] + end, + minus = function(class, name) + local class_name = 'class' .. name + return [[ + local ]] .. class_name .. [[ = ]] .. class .. [[ + + add(function(ctx) -- minus + -- debug(ctx, 'minus', ']] .. class_name .. [[') + + local clone = ctx:clone() + local pos + repeat + pos = clone.pos + clone:next_function() + clone:get_function()(clone) + if clone.modified then + clone = ctx:clone() + clone.pos = pos + else + clone.pos = pos + clone.func_pos = ctx.func_pos + end + local match = ]] .. class_name .. [[:test(clone:get_charcode()) + clone:next_char() + until not match + end) +]] + end, + question = function(class, name) + local class_name = 'class' .. name + return [[ + local ]] .. class_name .. [[ = ]] .. class .. [[ + + add(function(ctx) -- question + -- debug(ctx, 'question', ']] .. class_name .. [[') + local saved = ctx:clone() + if ]] .. class_name .. [[:test(ctx:get_charcode()) then + ctx:next_char() + ctx:next_function() + ctx:get_function()(ctx) + end + ctx = saved + ctx:next_function() + return ctx:get_function()(ctx) + end) +]] + end, + capture_start = function(number) + return [[ + add(function(ctx) + ctx.modified = true + -- debug(ctx, 'capture_start', ']] .. tostring(number) .. [[') + table.insert(ctx.captures.active, { id = ]] .. tostring(number) .. [[, start = ctx.pos }) + ctx:next_function() + return ctx:get_function()(ctx) + end) +]] + end, + capture_finish = function(number) + return [[ + add(function(ctx) + ctx.modified = true + -- debug(ctx, 'capture_finish', ']] .. tostring(number) .. [[') + local cap = table.remove(ctx.captures.active) + cap.finish = ctx.pos + local b, e = ctx.offsets[cap.start], ctx.offsets[cap.finish] + if cap.start < 1 then + b = 1 + elseif cap.start >= ctx.len then + b = ctx.rawlen + 1 + end + if cap.finish < 1 then + e = 1 + elseif cap.finish >= ctx.len then + e = ctx.rawlen + 1 + end + ctx.captures[cap.id] = rawsub(ctx.str, b, e - 1) + -- debug('capture#' .. tostring(cap.id), '[' .. tostring(cap.start).. ',' .. tostring(cap.finish) .. ']' , 'is', ctx.captures[cap.id]) + ctx:next_function() + return ctx:get_function()(ctx) + end) +]] + end, + capture_position = function(number) + return [[ + add(function(ctx) + ctx.modified = true + -- debug(ctx, 'capture_position', ']] .. tostring(number) .. [[') + ctx.captures[ ]] .. tostring(number) .. [[ ] = ctx.pos + ctx:next_function() + return ctx:get_function()(ctx) + end) +]] + end, + capture = function(number) + return [[ + add(function(ctx) + -- debug(ctx, 'capture', ']] .. tostring(number) .. [[') + local cap = ctx.captures[ ]] .. tostring(number) .. [[ ] + local len = utf8len(cap) + local check = utf8sub(ctx.str, ctx.pos, ctx.pos + len - 1) + -- debug("capture check:", cap, check) + if cap == check then + ctx.pos = ctx.pos + len + ctx:next_function() + return ctx:get_function()(ctx) + end + end) +]] + end, + balancer = function(pair, name) + local class_name = 'class' .. name + return [[ + + add(function(ctx) -- balancer + local d, b = ]] .. tostring(utf8unicode(pair[1])) .. [[, ]] .. tostring(utf8unicode(pair[2])) .. [[ + if ctx:get_charcode() ~= d then return end + local balance = 0 + repeat + local c = ctx:get_charcode() + if c == nil then return end + + if c == d then + balance = balance + 1 + elseif c == b then + balance = balance - 1 + end + -- debug("balancer: balance=", balance, ", d=", d, ", b=", b, ", charcode=", ctx:get_charcode()) + ctx:next_char() + until balance == 0 or (balance == 2 and d == b) + ctx:next_function() + return ctx:get_function()(ctx) + end) +]] + end, + simple = utf8:require("modifier.compiletime.simple").simple, +} + +local next = utf8.util.next + +local function parse(regex, c, bs, ctx) + local functions, nbs = nil, bs + if c == '%' then + c, nbs = next(regex, bs) + utf8.debug("next", c, bs) + if c == '' then + error("malformed pattern (ends with '%')") + end + if utf8.raw.find('123456789', c, 1, true) then + functions = { matchers.capture(tonumber(c)) } + nbs = utf8.next(regex, nbs) + elseif c == 'b' then + local d, b + d, nbs = next(regex, nbs) + b, nbs = next(regex, nbs) + assert(d ~= '' and b ~= '', "unbalanced pattern") + functions = { matchers.balancer({d, b}, tostring(bs)) } + nbs = utf8.next(regex, nbs) + end + + if functions and ctx.prev_class then + table.insert(functions, 1, matchers.simple(ctx.prev_class, tostring(bs))) + end + elseif c == '*' and ctx.prev_class then + functions = { + matchers.star( + ctx.prev_class, + tostring(bs) + ) + } + nbs = bs + 1 + elseif c == '+' and ctx.prev_class then + functions = { + matchers.simple( + ctx.prev_class, + tostring(bs) + ), + matchers.star( + ctx.prev_class, + tostring(bs) + ) + } + nbs = bs + 1 + elseif c == '-' and ctx.prev_class then + functions = { + matchers.minus( + ctx.prev_class, + tostring(bs) + ) + } + nbs = bs + 1 + elseif c == '?' and ctx.prev_class then + functions = { + matchers.question( + ctx.prev_class, + tostring(bs) + ) + } + nbs = bs + 1 + elseif c == '(' then + ctx.capture = ctx.capture or {balance = 0, id = 0} + ctx.capture.id = ctx.capture.id + 1 + local nc = next(regex, nbs) + if nc == ')' then + functions = {matchers.capture_position(ctx.capture.id)} + nbs = bs + 2 + else + ctx.capture.balance = ctx.capture.balance + 1 + functions = {matchers.capture_start(ctx.capture.id)} + nbs = bs + 1 + end + if ctx.prev_class then + table.insert(functions, 1, matchers.simple(ctx.prev_class, tostring(bs))) + end + elseif c == ')' then + ctx.capture = ctx.capture or {balance = 0, id = 0} + functions = { matchers.capture_finish(ctx.capture.id) } + + ctx.capture.balance = ctx.capture.balance - 1 + assert(ctx.capture.balance >= 0, 'invalid capture: "(" missing') + + if ctx.prev_class then + table.insert(functions, 1, matchers.simple(ctx.prev_class, tostring(bs))) + end + nbs = bs + 1 + end + + return functions, nbs - bs +end + +local function check(ctx) + if ctx.capture then assert(ctx.capture.balance == 0, 'invalid capture: ")" missing') end +end + +return { + parse = parse, + check = check, +} + +end diff --git a/mac/.config/mpv/script-modules/utf8/primitives/dummy.lua b/mac/.config/mpv/script-modules/utf8/primitives/dummy.lua new file mode 100644 index 0000000..a4665f5 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/primitives/dummy.lua @@ -0,0 +1,555 @@ +-- $Id: utf8.lua 179 2009-04-03 18:10:03Z pasta $ +-- +-- Provides UTF-8 aware string functions implemented in pure lua: +-- * utf8len(s) +-- * utf8sub(s, i, j) +-- * utf8reverse(s) +-- * utf8char(unicode) +-- * utf8unicode(s, i, j) +-- * utf8gensub(s, sub_len) +-- * utf8find(str, regex, init, plain) +-- * utf8match(str, regex, init) +-- * utf8gmatch(str, regex, all) +-- * utf8gsub(str, regex, repl, limit) +-- +-- All functions behave as their non UTF-8 aware counterparts with the exception +-- that UTF-8 characters are used instead of bytes for all units. + +--[[ +Copyright (c) 2006-2007, Kyle Smith +All rights reserved. + +Contributors: + Alimov Stepan + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the author nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--]] + +-- ABNF from RFC 3629 +-- +-- UTF8-octets = *( UTF8-char ) +-- UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4 +-- UTF8-1 = %x00-7F +-- UTF8-2 = %xC2-DF UTF8-tail +-- UTF8-3 = %xE0 %xA0-BF UTF8-tail / %xE1-EC 2( UTF8-tail ) / +-- %xED %x80-9F UTF8-tail / %xEE-EF 2( UTF8-tail ) +-- UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) / +-- %xF4 %x80-8F 2( UTF8-tail ) +-- UTF8-tail = %x80-BF +-- +return function(utf8) + +local byte = string.byte +local char = string.char +local dump = string.dump +local find = string.find +local format = string.format +local len = string.len +local lower = string.lower +local rep = string.rep +local sub = string.sub +local upper = string.upper + +local utf8charpattern = '[%z\1-\127\194-\244][\128-\191]*' + +local function utf8symbollen(byte) + return not byte and 0 or (byte < 0x80 and 1) or (byte >= 0xF0 and 4) or (byte >= 0xE0 and 3) or (byte >= 0xC0 and 2) or 1 +end + +local head_table = utf8.config.int32array(256) +for i = 0, 255 do + head_table[i] = utf8symbollen(i) +end +head_table[256] = 0 + +local function utf8charbytes(str, bs) + return head_table[byte(str, bs) or 256] +end + +local function utf8next(str, bs) + return bs + utf8charbytes(str, bs) +end + +-- returns the number of characters in a UTF-8 string +local function utf8len (str) + local bs = 1 + local bytes = len(str) + local length = 0 + + while bs <= bytes do + length = length + 1 + bs = utf8next(str, bs) + end + + return length +end + +-- functions identically to string.sub except that i and j are UTF-8 characters +-- instead of bytes +local function utf8sub (s, i, j) + -- argument defaults + j = j or -1 + + local bs = 1 + local bytes = len(s) + local length = 0 + + local l = (i >= 0 and j >= 0) or utf8len(s) + i = (i >= 0) and i or l + i + 1 + j = (j >= 0) and j or l + j + 1 + + if i > j then + return "" + end + + local start, finish = 1, bytes + + while bs <= bytes do + length = length + 1 + + if length == i then + start = bs + end + + bs = utf8next(s, bs) + + if length == j then + finish = bs - 1 + break + end + end + + if i > length then start = bytes + 1 end + if j < 1 then finish = 0 end + + return sub(s, start, finish) +end + +-- http://en.wikipedia.org/wiki/Utf8 +-- http://developer.coronalabs.com/code/utf-8-conversion-utility +local function utf8char(...) + local codes = {...} + local result = {} + + for _, unicode in ipairs(codes) do + + if unicode <= 0x7F then + result[#result + 1] = unicode + elseif unicode <= 0x7FF then + local b0 = 0xC0 + math.floor(unicode / 0x40); + local b1 = 0x80 + (unicode % 0x40); + result[#result + 1] = b0 + result[#result + 1] = b1 + elseif unicode <= 0xFFFF then + local b0 = 0xE0 + math.floor(unicode / 0x1000); + local b1 = 0x80 + (math.floor(unicode / 0x40) % 0x40); + local b2 = 0x80 + (unicode % 0x40); + result[#result + 1] = b0 + result[#result + 1] = b1 + result[#result + 1] = b2 + elseif unicode <= 0x10FFFF then + local code = unicode + local b3= 0x80 + (code % 0x40); + code = math.floor(code / 0x40) + local b2= 0x80 + (code % 0x40); + code = math.floor(code / 0x40) + local b1= 0x80 + (code % 0x40); + code = math.floor(code / 0x40) + local b0= 0xF0 + code; + + result[#result + 1] = b0 + result[#result + 1] = b1 + result[#result + 1] = b2 + result[#result + 1] = b3 + else + error 'Unicode cannot be greater than U+10FFFF!' + end + + end + + return char(utf8.config.unpack(result)) +end + + +local shift_6 = 2^6 +local shift_12 = 2^12 +local shift_18 = 2^18 + +local utf8unicode +utf8unicode = function(str, ibs, jbs) + if ibs > jbs then return end + + local ch,bytes + + bytes = utf8charbytes(str, ibs) + if bytes == 0 then return end + + local unicode + + if bytes == 1 then unicode = byte(str, ibs, ibs) end + if bytes == 2 then + local byte0,byte1 = byte(str, ibs, ibs + 1) + if byte0 and byte1 then + local code0,code1 = byte0-0xC0,byte1-0x80 + unicode = code0*shift_6 + code1 + else + unicode = byte0 + end + end + if bytes == 3 then + local byte0,byte1,byte2 = byte(str, ibs, ibs + 2) + if byte0 and byte1 and byte2 then + local code0,code1,code2 = byte0-0xE0,byte1-0x80,byte2-0x80 + unicode = code0*shift_12 + code1*shift_6 + code2 + else + unicode = byte0 + end + end + if bytes == 4 then + local byte0,byte1,byte2,byte3 = byte(str, ibs, ibs + 3) + if byte0 and byte1 and byte2 and byte3 then + local code0,code1,code2,code3 = byte0-0xF0,byte1-0x80,byte2-0x80,byte3-0x80 + unicode = code0*shift_18 + code1*shift_12 + code2*shift_6 + code3 + else + unicode = byte0 + end + end + + if ibs == jbs then + return unicode + else + return unicode,utf8unicode(str, ibs+bytes, jbs) + end +end + +local function utf8byte(str, i, j) + if #str == 0 then return end + + local ibs, jbs + + if i or j then + i = i or 1 + j = j or i + + local str_len = utf8len(str) + i = i < 0 and str_len + i + 1 or i + j = j < 0 and str_len + j + 1 or j + j = j > str_len and str_len or j + + if i > j then return end + + for p = 1, i - 1 do + ibs = utf8next(str, ibs or 1) + end + + if i == j then + jbs = ibs + else + for p = 1, j - 1 do + jbs = utf8next(str, jbs or 1) + end + end + + if not ibs or not jbs then + return nil + end + else + ibs, jbs = 1, 1 + end + + return utf8unicode(str, ibs, jbs) +end + +local function utf8gensub(str, sub_len) + sub_len = sub_len or 1 + local max_len = #str + return function(skip_ptr, bs) + bs = (bs and bs or 1) + (skip_ptr and (skip_ptr[1] or 0) or 0) + + local nbs = bs + if bs > max_len then return nil end + for i = 1, sub_len do + nbs = utf8next(str, nbs) + end + + return nbs, sub(str, bs, nbs - 1), bs + end +end + +local function utf8reverse (s) + local result = '' + for _, w in utf8gensub(s) do result = w .. result end + return result +end + +local function utf8validator(str, bs) + bs = bs or 1 + + if type(str) ~= "string" then + error("bad argument #1 to 'utf8charbytes' (string expected, got ".. type(str).. ")") + end + if type(bs) ~= "number" then + error("bad argument #2 to 'utf8charbytes' (number expected, got ".. type(bs).. ")") + end + + local c = byte(str, bs) + if not c then return end + + -- determine bytes needed for character, based on RFC 3629 + + -- UTF8-1 + if c >= 0 and c <= 127 then + return bs + 1 + elseif c >= 128 and c <= 193 then + return bs + 1, bs, 1, c + -- UTF8-2 + elseif c >= 194 and c <= 223 then + local c2 = byte(str, bs + 1) + if not c2 or c2 < 128 or c2 > 191 then + return bs + 2, bs, 2, c2 + end + + return bs + 2 + -- UTF8-3 + elseif c >= 224 and c <= 239 then + local c2 = byte(str, bs + 1) + + if not c2 then + return bs + 2, bs, 2, c2 + end + + -- validate byte 2 + if c == 224 and (c2 < 160 or c2 > 191) then + return bs + 2, bs, 2, c2 + elseif c == 237 and (c2 < 128 or c2 > 159) then + return bs + 2, bs, 2, c2 + elseif c2 < 128 or c2 > 191 then + return bs + 2, bs, 2, c2 + end + + local c3 = byte(str, bs + 2) + if not c3 or c3 < 128 or c3 > 191 then + return bs + 3, bs, 3, c3 + end + + return bs + 3 + -- UTF8-4 + elseif c >= 240 and c <= 244 then + local c2 = byte(str, bs + 1) + + if not c2 then + return bs + 2, bs, 2, c2 + end + + -- validate byte 2 + if c == 240 and (c2 < 144 or c2 > 191) then + return bs + 2, bs, 2, c2 + elseif c == 244 and (c2 < 128 or c2 > 143) then + return bs + 2, bs, 2, c2 + elseif c2 < 128 or c2 > 191 then + return bs + 2, bs, 2, c2 + end + + local c3 = byte(str, bs + 2) + if not c3 or c3 < 128 or c3 > 191 then + return bs + 3, bs, 3, c3 + end + + local c4 = byte(str, bs + 3) + if not c4 or c4 < 128 or c4 > 191 then + return bs + 4, bs, 4, c4 + end + + return bs + 4 + else -- c > 245 + return bs + 1, bs, 1, c + end +end + +local function utf8validate(str, byte_pos) + local result = {} + for nbs, bs, part, code in utf8validator, str, byte_pos do + if bs then + result[#result + 1] = { pos = bs, part = part, code = code } + end + end + return #result == 0, result +end + +local function utf8codes(str) + local max_len = #str + local bs = 1 + return function(skip_ptr) + if bs > max_len then return nil end + local pbs = bs + bs = utf8next(str, pbs) + + return pbs, utf8unicode(str, pbs, pbs), pbs + end +end + + +--[[-- +differs from Lua 5.3 utf8.offset in accepting any byte positions (not only head byte) for all n values + +h - head, c - continuation, t - tail +hhhccthccthccthcthhh + ^ start byte pos +searching current charracter head by moving backwards +hhhccthccthccthcthhh + ^ head + +n == 0: current position +n > 0: n jumps forward +n < 0: n more scans backwards +--]]-- +local function utf8offset(str, n, bs) + local l = #str + if not bs then + if n < 0 then + bs = l + 1 + else + bs = 1 + end + end + if bs <= 0 or bs > l + 1 then + error("bad argument #3 to 'offset' (position out of range)") + end + + if n == 0 then + if bs == l + 1 then + return bs + end + while true do + local b = byte(str, bs) + if (0 < b and b < 127) + or (194 < b and b < 244) then + return bs + end + bs = bs - 1 + if bs < 1 then + return + end + end + elseif n < 0 then + bs = bs - 1 + repeat + if bs < 1 then + return + end + + local b = byte(str, bs) + if (0 < b and b < 127) + or (194 < b and b < 244) then + n = n + 1 + end + bs = bs - 1 + until n == 0 + return bs + 1 + else + while true do + if bs > l then + return + end + + local b = byte(str, bs) + if (0 < b and b < 127) + or (194 < b and b < 244) then + n = n - 1 + for i = 1, n do + if bs > l then + return + end + bs = utf8next(str, bs) + end + return bs + end + bs = bs - 1 + end + end + +end + +local function utf8replace (s, mapping) + if type(s) ~= "string" then + error("bad argument #1 to 'utf8replace' (string expected, got ".. type(s).. ")") + end + if type(mapping) ~= "table" then + error("bad argument #2 to 'utf8replace' (table expected, got ".. type(mapping).. ")") + end + local result = utf8.raw.gsub( s, utf8charpattern, mapping ) + return result +end + +local function utf8upper (s) + return utf8replace(s, utf8.config.conversion.lc_uc) +end + +if utf8.config.conversion.lc_uc then + upper = utf8upper +end + +local function utf8lower (s) + return utf8replace(s, utf8.config.conversion.uc_lc) +end + +if utf8.config.conversion.uc_lc then + lower = utf8lower +end + +utf8.len = utf8len +utf8.sub = utf8sub +utf8.reverse = utf8reverse +utf8.char = utf8char +utf8.unicode = utf8unicode +utf8.byte = utf8byte +utf8.next = utf8next +utf8.gensub = utf8gensub +utf8.validator = utf8validator +utf8.validate = utf8validate +utf8.dump = dump +utf8.format = format +utf8.lower = lower +utf8.upper = upper +utf8.rep = rep +utf8.raw = {} +for k,v in pairs(string) do + utf8.raw[k] = v +end + +utf8.charpattern = utf8charpattern +utf8.offset = utf8offset +if _VERSION == 'Lua 5.3' then + local utf8_53 = require "utf8" + utf8.codes = utf8_53.codes + utf8.codepoint = utf8_53.codepoint + utf8.len53 = utf8_53.len +else + utf8.codes = utf8codes + utf8.codepoint = utf8unicode +end + +return utf8 + +end diff --git a/mac/.config/mpv/script-modules/utf8/primitives/init.lua b/mac/.config/mpv/script-modules/utf8/primitives/init.lua new file mode 100644 index 0000000..df28ef3 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/primitives/init.lua @@ -0,0 +1,23 @@ +return function(utf8) + +local provided = utf8.config.primitives + +if provided then + if type(provided) == "table" then + return provided + elseif type(provided) == "function" then + return provided(utf8) + else + return utf8:require(provided) + end +end + +if pcall(require, "tarantool") then + return utf8:require "primitives.tarantool" +elseif pcall(require, "ffi") then + return utf8:require "primitives.native" +else + return utf8:require "primitives.dummy" +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/primitives/native.lua b/mac/.config/mpv/script-modules/utf8/primitives/native.lua new file mode 100644 index 0000000..c9aca54 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/primitives/native.lua @@ -0,0 +1,57 @@ +return function(utf8) + +local ffi = require("ffi") +if ffi.os == "Windows" then + os.setlocale(utf8.config.locale or "english_us.65001", "ctype") + ffi.cdef[[ + short towupper(short c); + short towlower(short c); + ]] +else + os.setlocale(utf8.config.locale or "C.UTF-8", "ctype") + ffi.cdef[[ + int towupper(int c); + int towlower(int c); + ]] +end + +utf8:require "primitives.dummy" + +if not utf8.config.conversion.uc_lc then + function utf8.lower(str) + local bs = 1 + local nbs + local bytes = utf8.raw.len(str) + local res = {} + + while bs <= bytes do + nbs = utf8.next(str, bs) + local cp = utf8.unicode(str, bs, nbs) + res[#res + 1] = ffi.C.towlower(cp) + bs = nbs + end + + return utf8.char(utf8.config.unpack(res)) + end +end + +if not utf8.config.conversion.lc_uc then + function utf8.upper(str) + local bs = 1 + local nbs + local bytes = utf8.raw.len(str) + local res = {} + + while bs <= bytes do + nbs = utf8.next(str, bs) + local cp = utf8.unicode(str, bs, nbs) + res[#res + 1] = ffi.C.towupper(cp) + bs = nbs + end + + return utf8.char(utf8.config.unpack(res)) + end +end + +return utf8 +end diff --git a/mac/.config/mpv/script-modules/utf8/primitives/tarantool.lua b/mac/.config/mpv/script-modules/utf8/primitives/tarantool.lua new file mode 100644 index 0000000..c38acf6 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/primitives/tarantool.lua @@ -0,0 +1,13 @@ +return function(utf8) + +utf8:require "primitives.dummy" + +local tnt_utf8 = utf8.config.tarantool_utf8 or require("utf8") + +utf8.lower = tnt_utf8.lower +utf8.upper = tnt_utf8.upper +utf8.len = tnt_utf8.len +utf8.char = tnt_utf8.char + +return utf8 +end diff --git a/mac/.config/mpv/script-modules/utf8/regex_parser.lua b/mac/.config/mpv/script-modules/utf8/regex_parser.lua new file mode 100644 index 0000000..3190f1b --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/regex_parser.lua @@ -0,0 +1,80 @@ +return function(utf8) + +utf8:require "modifier.compiletime.parser" +utf8:require "charclass.compiletime.parser" +utf8:require "begins.compiletime.parser" +utf8:require "ends.compiletime.parser" + +local gensub = utf8.gensub +local sub = utf8.sub + +local parser_context = utf8:require "context.compiletime" + +return function(regex, plain) + utf8.debug("regex", regex) + local ctx = parser_context:new() + + local skip = {0} + for nbs, c, bs in gensub(regex, 0), skip do + repeat -- continue + skip[1] = 0 + + c = utf8.raw.sub(regex, bs, utf8.next(regex, bs) - 1) + + local functions, move = utf8.regex.compiletime.begins.parse(regex, c, bs, ctx) + if functions then + ctx.begins = functions + skip[1] = move + end + if skip[1] ~= 0 then break end + + local functions, move = utf8.regex.compiletime.ends.parse(regex, c, bs, ctx) + if functions then + ctx.ends = functions + skip[1] = move + end + if skip[1] ~= 0 then break end + + local functions, move = utf8.regex.compiletime.modifier.parse(regex, c, bs, ctx) + if functions then + for _, f in ipairs(functions) do + ctx.funcs[#ctx.funcs + 1] = f + end + skip[1] = move + end + if skip[1] ~= 0 then break end + + local charclass, move = utf8.regex.compiletime.charclass.parse(regex, c, bs, ctx) + if charclass then skip[1] = move end + until true -- continue + end + + for _, m in ipairs(utf8.config.modifier) do + if m.check then m.check(ctx) end + end + + local src = [[ + return function(str, init, utf8) + local ctx = utf8:require("context.runtime").new({str = str, pos = init or 1}) + local cl = utf8:require("charclass.runtime.init") + local utf8sub = utf8.sub + local rawsub = utf8.raw.sub + local utf8len = utf8.len + local utf8next = utf8.next + local debug = utf8.debug + local function add(fun) + ctx.functions[#ctx.functions + 1] = fun + end + ]] .. ctx.begins + for _, v in ipairs(ctx.funcs) do src = src .. v end + src = src .. ctx.ends .. [[ + return coroutine.wrap(ctx:get_function())(ctx) + end + ]] + + utf8.debug(regex, src) + + return assert(utf8.config.loadstring(src, (plain and "plain " or "") .. regex))() +end + +end diff --git a/mac/.config/mpv/script-modules/utf8/test.sh b/mac/.config/mpv/script-modules/utf8/test.sh new file mode 100755 index 0000000..b8d2d63 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -xe + +lua53=$(which lua5.3 || which true) +lua51=$(which lua5.1 || which true) +luajit=$(which luajit || which true) + +for test in \ + test/charclass_compiletime.lua \ + test/charclass_runtime.lua \ + test/context_runtime.lua \ + test/test.lua \ + test/test_compat.lua \ + test/test_pm.lua \ + test/test_utf8data.lua +do + $lua53 $test + $lua51 $test + $luajit $test +done + +echo "tests passed" diff --git a/mac/.config/mpv/script-modules/utf8/test/charclass_compiletime.lua b/mac/.config/mpv/script-modules/utf8/test/charclass_compiletime.lua new file mode 100644 index 0000000..05d762d --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/charclass_compiletime.lua @@ -0,0 +1,165 @@ +local utf8 = require "init" +utf8.config = { + debug = nil, +-- debug = utf8:require("util").debug, +} +utf8:init() + +local ctx = utf8:require("context.compiletime"):new() + +local equals = require 'test.util'.equals +local assert = require 'test.util'.assert +local assert_equals = require 'test.util'.assert_equals +local parse = utf8.regex.compiletime.charclass.parse + +assert_equals({parse("aabb", "a", 1, ctx)}, {{codes = {utf8.byte("a")}}, 1}) +assert_equals({parse("aabb", "a", 2, ctx)}, {{codes = {utf8.byte("a")}}, 1}) +assert_equals({parse("aabb", "b", 3, ctx)}, {{codes = {utf8.byte("b")}}, 1}) +assert_equals({parse("aabb", "b", 4, ctx)}, {{codes = {utf8.byte("b")}}, 1}) + +assert_equals({parse("aa%ab", "%", 3, ctx)}, {{classes = {'alpha'}}, 2}) +assert_equals({parse("aac%Ab", "%", 4, ctx)}, {{not_classes = {'alpha'}}, 2}) +assert_equals({parse("aa.b", ".", 3, ctx)}, {{inverted = true}, 1}) + +assert_equals({parse("aa[c]b", "[", 3, ctx)}, { + {codes = {utf8.byte("c")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[c]") +}) + +assert_equals({parse("aa[%A]b", "[", 3, ctx)}, { + {codes = nil, ranges = nil, classes = nil, not_classes = {'alpha'}}, + utf8.raw.len("[%A]") +}) + +assert_equals({parse("[^%p%d%s%c]+", "[", 1, ctx)}, { + {codes = nil, ranges = nil, classes = {'punct', 'digit', 'space', 'cntrl'}, not_classes = nil, inverted = true}, + utf8.raw.len("[^%p%d%s%c]") +}) + +assert_equals({parse("aa[[c]]b", "[", 3, ctx)}, { + {codes = {utf8.byte("["), utf8.byte("c")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[[c]") +}) + +assert_equals({parse("aa[%a[c]]b", "[", 3, ctx)}, { + {codes = {utf8.byte("["), utf8.byte("c")}, ranges = nil, classes = {'alpha'}, not_classes = nil}, + utf8.raw.len("[%a[c]") +}) + +assert_equals({parse("aac-db", "c", 3, ctx)}, { + {codes = {utf8.byte("c")}}, + utf8.raw.len("c") +}) + +assert_equals({parse("aa[c-d]b", "[", 3, ctx)}, { + {codes = nil, ranges = {{utf8.byte("c"),utf8.byte("d")}}, classes = nil, not_classes = nil}, + utf8.raw.len("[c-d]") +}) +assert_equals(ctx.internal, false) + +assert_equals({parse("aa[c-]]b", "[", 3, ctx)}, { + {codes = {utf8.byte("-"), utf8.byte("c")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[c-]") +}) +assert_equals(ctx.internal, false) + +assert_equals({parse("aad-", "d", 3, ctx)}, { + {codes = {utf8.byte("d")}}, + utf8.raw.len("d") +}) +assert_equals(ctx.internal, false) + +ctx.internal = false +assert_equals({parse(".", ".", 1, ctx)}, { + {inverted = true}, + utf8.raw.len(".") +}) + +assert_equals({parse("[.]", "[", 1, ctx)}, { + {codes = {utf8.byte(".")}}, + utf8.raw.len("[.]") +}) + +assert_equals({parse("%?", "%", 1, ctx)}, { + {codes = {utf8.byte("?")}}, + utf8.raw.len("%?") +}) + +assert_equals({parse("[]]", "[", 1, ctx)}, { + {codes = {utf8.byte("]")}}, + utf8.raw.len("[]]") +}) + +assert_equals({parse("[^]]", "[", 1, ctx)}, { + {codes = {utf8.byte("]")}, inverted = true}, + utf8.raw.len("[^]]") +}) + +--[[-- +multibyte chars +--]]-- + +assert_equals({parse("ббюю", "б", #"" + 1, ctx)}, {{codes = {utf8.byte("б")}}, utf8.raw.len("б")}) +assert_equals({parse("ббюю", "б", #"б" + 1, ctx)}, {{codes = {utf8.byte("б")}}, utf8.raw.len("б")}) +assert_equals({parse("ббюю", "ю", #"бб" + 1, ctx)}, {{codes = {utf8.byte("ю")}}, utf8.raw.len("ю")}) +assert_equals({parse("ббюю", "ю", #"ббю" + 1, ctx)}, {{codes = {utf8.byte("ю")}}, utf8.raw.len("ю")}) + +assert_equals({parse("бб%aю", "%", #"бб" + 1, ctx)}, {{classes = {'alpha'}}, 2}) +assert_equals({parse("ббц%Aю", "%", #"ббц" + 1, ctx)}, {{not_classes = {'alpha'}}, 2}) +assert_equals({parse("бб.ю", ".", #"бб" + 1, ctx)}, {{inverted = true}, 1}) + +assert_equals({parse("бб[ц]ю", "[", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("ц")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[ц]") +}) + +assert_equals({parse("бб[%A]ю", "[", #"бб" + 1, ctx)}, { + {codes = nil, ranges = nil, classes = nil, not_classes = {'alpha'}}, + utf8.raw.len("[%A]") +}) + +assert_equals({parse("бб[[ц]]ю", "[", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("["), utf8.byte("ц")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[[ц]") +}) + +assert_equals({parse("бб[%a[ц]]ю", "[", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("["), utf8.byte("ц")}, ranges = nil, classes = {'alpha'}, not_classes = nil}, + utf8.raw.len("[%a[ц]") +}) + +ctx.internal = true +assert_equals({parse("ббц-ыю", "ц", #"бб" + 1, ctx)}, { + {ranges = {{utf8.byte("ц"),utf8.byte("ы")}}}, + utf8.raw.len("ц-ы") +}) + +ctx.internal = false +assert_equals({parse("бб[ц-ы]ю", "[", #"бб" + 1, ctx)}, { + {codes = nil, ranges = {{utf8.byte("ц"),utf8.byte("ы")}}, classes = nil, not_classes = nil}, + utf8.raw.len("[ц-ы]") +}) + +assert_equals({parse("бб[ц-]]ю", "[", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("-"), utf8.byte("ц")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[ц-]") +}) + +assert_equals({parse("ббы-", "ы", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("ы")}}, + utf8.raw.len("ы") +}) + +ctx.internal = true +assert_equals({parse("ббы-цю", "ы", #"бб" + 1, ctx)}, { + {ranges = {{utf8.byte("ы"),utf8.byte("ц")}}}, + utf8.raw.len("ы-ц") +}) + +ctx.internal = false +assert_equals({parse("бб[ы]ю", "[", #"бб" + 1, ctx)}, { + {codes = {utf8.byte("ы")}, ranges = nil, classes = nil, not_classes = nil}, + utf8.raw.len("[ы]") +}) + +print "OK" diff --git a/mac/.config/mpv/script-modules/utf8/test/charclass_runtime.lua b/mac/.config/mpv/script-modules/utf8/test/charclass_runtime.lua new file mode 100644 index 0000000..616af14 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/charclass_runtime.lua @@ -0,0 +1,116 @@ +local utf8 = require("init") +utf8.config = { + debug = nil, --utf8:require("util").debug +} +utf8:init() + +local cl = utf8:require("charclass.runtime.init") + +local equals = require('test.util').equals +local assert = require('test.util').assert +local assert_equals = require('test.util').assert_equals + +assert_equals(true, cl.new() + :with_codes(utf8.byte' ') + :invert() + :in_codes(utf8.byte' ')) + +assert_equals(false, cl.new() + :with_codes(utf8.byte' ') + :invert() + :test(utf8.byte' ')) + +assert_equals(false, cl.new() + :with_codes() + :with_ranges() + :with_classes('space') + :without_classes() + :with_subs() + :invert() + :test(utf8.byte(' '))) + +assert_equals(true, cl.new() + :with_codes() + :with_ranges() + :with_classes() + :without_classes('space') + :with_subs() + :invert() + :test(utf8.byte(' '))) + +assert_equals(false, cl.new() + :with_codes() + :with_ranges() + :with_classes() + :without_classes() + :with_subs(cl.new():with_classes('space')) + :invert() + :test(utf8.byte(' '))) + +assert_equals(true, cl.new() + :with_codes() + :with_ranges() + :with_classes() + :without_classes() + :with_subs(cl.new():with_classes('space'):invert()) + :invert() + :test(utf8.byte(' '))) + +assert_equals(true, cl.new() + :with_codes() + :with_ranges() + :with_classes('punct', 'digit', 'space', 'cntrl') + :without_classes() + :with_subs() + :invert() + :test(utf8.byte'П') +) + +assert_equals(true, cl.new() + :with_codes() + :with_ranges() + :with_classes('punct', 'digit', 'space', 'cntrl') + :without_classes() + :with_subs() + :invert() + :test(utf8.byte'и') +) + +assert_equals(true, cl.new() + :with_codes() + :with_ranges() + :with_classes() + :without_classes('space') + :with_subs() + :test(utf8.byte'f') +) + +assert_equals(false, cl.new() + :with_codes() + :with_ranges() + :with_classes() + :without_classes('space') + :with_subs() + :test(utf8.byte'\n') +) + +assert_equals(false, cl.new() + :with_codes() + :with_ranges() + :with_classes('lower') + :without_classes() + :with_subs() + :invert() + :test(nil) +) + +assert_equals(false, cl.new() + :with_codes() + :with_ranges() + :with_classes('lower') + :without_classes() + :with_subs() + :test(nil) +) + +print "OK" diff --git a/mac/.config/mpv/script-modules/utf8/test/context_runtime.lua b/mac/.config/mpv/script-modules/utf8/test/context_runtime.lua new file mode 100644 index 0000000..9a177bf --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/context_runtime.lua @@ -0,0 +1,82 @@ +local utf8 = require("init"):init() + +local context = utf8:require('context.runtime') + +local equals = require('test.util').equals +local assert = require('test.util').assert +local assert_equals = require('test.util').assert_equals + +local ctx_en +local ctx_ru +local function setup() + ctx_en = context.new({str = 'asdf'}) + ctx_ru = context.new({str = 'фыва'}) +end + +local test_get_char = (function() + setup() + + assert_equals('a', ctx_en:get_char()) + assert_equals('ф', ctx_ru:get_char()) +end)() + +local test_get_charcode = (function() + setup() + + assert_equals(utf8.byte'a', ctx_en:get_charcode()) + assert_equals(utf8.byte'ф', ctx_ru:get_charcode()) +end)() + +local test_next_char = (function() + setup() + + assert_equals(1, ctx_en.pos) + assert_equals(1, ctx_ru.pos) + + ctx_ru:next_char() + ctx_en:next_char() + + assert_equals(2, ctx_en.pos) + assert_equals(2, ctx_ru.pos) + + assert_equals('s', ctx_en:get_char()) + assert_equals('ы', ctx_ru:get_char()) + assert_equals(utf8.byte's', ctx_en:get_charcode()) + assert_equals(utf8.byte'ы', ctx_ru:get_charcode()) +end)() + +local test_clone = (function() + setup() + + local clone = ctx_en:clone() + + assert(getmetatable(clone) == getmetatable(ctx_en)) + assert_equals(clone, ctx_en) + + ctx_en:next_char() + + assert_equals('a', clone:get_char()) + assert_equals('s', ctx_en:get_char()) + +end)() + +local test_last_char = (function() + ctx_en = context.new({str = 'asdf', pos = 4}) + ctx_ru = context.new({str = 'фыва', pos = 4}) + + assert_equals('f', ctx_en:get_char()) + assert_equals('а', ctx_ru:get_char()) + + ctx_ru:next_char() + ctx_en:next_char() + + assert_equals(5, ctx_en.pos) + assert_equals(5, ctx_ru.pos) + + assert_equals("", ctx_en:get_char()) + assert_equals("", ctx_ru:get_char()) + assert_equals(nil, ctx_en:get_charcode()) + assert_equals(nil, ctx_ru:get_charcode()) +end)() + +print('OK') diff --git a/mac/.config/mpv/script-modules/utf8/test/strict.lua b/mac/.config/mpv/script-modules/utf8/test/strict.lua new file mode 100644 index 0000000..7324644 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/strict.lua @@ -0,0 +1,42 @@ +--[[-- +strict.lua from http://metalua.luaforge.net/src/lib/strict.lua.html +--]]-- + +-- +-- strict.lua +-- checks uses of undeclared global variables +-- All global variables must be 'declared' through a regular assignment +-- (even assigning nil will do) in a main chunk before being used +-- anywhere or assigned to inside a function. +-- + +local mt = getmetatable(_G) +if mt == nil then + mt = {} + setmetatable(_G, mt) +end + +__STRICT = true +mt.__declared = {} + +mt.__newindex = function (t, n, v) + if __STRICT and not mt.__declared[n] then + local w = debug.getinfo(2, "S").what + if w ~= "main" and w ~= "C" then + error("assign to undeclared variable '"..n.."'", 2) + end + mt.__declared[n] = true + end + rawset(t, n, v) +end + +mt.__index = function (t, n) + if not mt.__declared[n] and debug.getinfo(2, "S").what ~= "C" then + error("variable '"..n.."' is not declared", 2) + end + return rawget(t, n) +end + +function global(...) + for _, v in ipairs{...} do mt.__declared[v] = true end +end diff --git a/mac/.config/mpv/script-modules/utf8/test/test.lua b/mac/.config/mpv/script-modules/utf8/test/test.lua new file mode 100644 index 0000000..8653b5d --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/test.lua @@ -0,0 +1,205 @@ +local utf8 = require('init') +utf8.config = { + debug = nil, +-- debug = utf8:require("util").debug, +} +utf8:init() + +for k,v in pairs(utf8) do + string[k] = v +end + +local LUA_51, LUA_53 = false, false +if "\xe4" == "xe4" then -- lua5.1 + LUA_51 = true +else -- luajit lua5.3 + LUA_53 = true +end + +local FFI_ENABLED = false +if pcall(require, "ffi") then + FFI_ENABLED = true +end + +local res = {} + +local equals = require 'test.util'.equals +local assert = require 'test.util'.assert +local assert_equals = require 'test.util'.assert_equals + +if FFI_ENABLED then + assert_equals(("АБВ"):lower(), "абв") + assert_equals(("абв"):upper(), "АБВ") +end + +res = {} +for _, w in ("123456789"):gensub(2), {1} do res[#res + 1] = w end +assert_equals({"23", "56", "89"}, res) + +assert_equals(0, ("фыва"):next(0)) +assert_equals(100, ("фыва"):next(100)) +assert_equals(#"ф" + 1, ("фыва"):next(1)) +assert_equals("ыва", utf8.raw.sub("фыва", ("фыва"):next(1))) + +res = {} +for p, c in ("абвгд"):codes() do res[#res + 1] = {p, c} end +assert_equals({ + {1, utf8.byte'а'}, + {#'а' + 1, utf8.byte'б'}, + {#'аб' + 1, utf8.byte'в'}, + {#'абв' + 1, utf8.byte'г'}, + {#'абвг' + 1, utf8.byte'д'}, +}, res) + +assert_equals(1, utf8.offset('abcde', 0)) + +assert_equals(1, utf8.offset('abcde', 1)) +assert_equals(5, utf8.offset('abcde', 5)) +assert_equals(6, utf8.offset('abcde', 6)) +assert_equals(nil, utf8.offset('abcde', 7)) + +assert_equals(5, utf8.offset('abcde', -1)) +assert_equals(1, utf8.offset('abcde', -5)) +assert_equals(nil, utf8.offset('abcde', -6)) + +assert_equals(1, utf8.offset('abcde', 0, 1)) +assert_equals(3, utf8.offset('abcde', 0, 3)) +assert_equals(6, utf8.offset('abcde', 0, 6)) + +assert_equals(3, utf8.offset('abcde', 1, 3)) +assert_equals(5, utf8.offset('abcde', 3, 3)) +assert_equals(6, utf8.offset('abcde', 4, 3)) +assert_equals(nil, utf8.offset('abcde', 5, 3)) + +assert_equals(2, utf8.offset('abcde', -1, 3)) +assert_equals(1, utf8.offset('abcde', -2, 3)) +assert_equals(5, utf8.offset('abcde', -1, 6)) +assert_equals(nil, utf8.offset('abcde', -3, 3)) + +assert_equals(1, utf8.offset('абвгд', 0)) + +assert_equals(1, utf8.offset('абвгд', 1)) +assert_equals(#'абвг' + 1, utf8.offset('абвгд', 5)) +assert_equals(#'абвгд' + 1, utf8.offset('абвгд', 6)) +assert_equals(nil, utf8.offset('абвгд', 7)) + +assert_equals(#'абвг' + 1, utf8.offset('абвгд', -1)) +assert_equals(1, utf8.offset('абвгд', -5)) +assert_equals(nil, utf8.offset('абвгд', -6)) + +assert_equals(1, utf8.offset('абвгд', 0, 1)) +assert_equals(1, utf8.offset('абвгд', 0, 2)) +assert_equals(#'аб' + 1, utf8.offset('абвгд', 0, #'аб' + 1)) +assert_equals(#'аб' + 1, utf8.offset('абвгд', 0, #'аб' + 2)) +assert_equals(#'абвгд' + 1, utf8.offset('абвгд', 0, #'абвгд' + 1)) + +assert_equals(#'аб' + 1, utf8.offset('абвгд', 1, #'аб' + 1)) +assert_equals(#'абвг' + 1, utf8.offset('абвгд', 3, #'аб' + 1)) +assert_equals(#'абвгд' + 1, utf8.offset('абвгд', 4, #'аб' + 1)) +assert_equals(#'абвгд' + 1, utf8.offset('абвгд', 4, #'аб' + 2)) +assert_equals(nil, utf8.offset('абвгд', 5, #'аб' + 1)) + +assert_equals(#'а' + 1, utf8.offset('абвгд', -1, #'аб' + 1)) +assert_equals(1, utf8.offset('абвгд', -2, #'аб' + 1)) +assert_equals(#'абвг' + 1, utf8.offset('абвгд', -1, #'абвгд' + 1)) +assert_equals(nil, utf8.offset('абвгд', -3, #'аб' + 1)) + +assert(("фыва"):validate()) +assert_equals({false, {{ pos = #"ф" + 1, part = 1, code = 255 }} }, {("ф\255ыва"):validate()}) +if LUA_53 then + assert_equals({false, {{ pos = #"ф" + 1, part = 1, code = 0xFF }} }, {("ф\xffыва"):validate()}) +end + +assert_equals(nil, ("aabb"):find("%bcd")) +assert_equals({1, 4}, {("aabb"):find("%bab")}) +assert_equals({1, 2}, {("aba"):find('%bab')}) + +res = {} +for w in ("aacaabbcabbacbaacab"):gmatch('%bab') do res[#res + 1] = w end +assert_equals({"acaabbcabb", "acb", "ab"}, res) + +assert_equals({1, 0}, {("aacaabbcabbacbaacab"):find('%f[acb]')}) +assert_equals("a", ("aba"):match('%f[ab].')) + +res = {} +for w in ("aacaabbcabbacbaacab"):gmatch('%f[ab]') do res[#res + 1] = w end +assert_equals({"", "", "", "", ""}, res) + +assert_equals({"HaacHaabbcHabbacHbaacHab", 5}, {("aacaabbcabbacbaacab"):gsub('%f[ab]', 'H')}) + +res = {} +for w in ("Привет, мир, от Lua"):gmatch("[^%p%d%s%c]+") do res[#res + 1] = w end +assert_equals({"Привет", "мир", "от", "Lua"}, res) + +res = {} +for k, v in ("从=世界, 到=Lua"):gmatch("([^%p%s%c]+)=([^%p%s%c]+)") do res[k] = v end +assert_equals({["到"] = "Lua", ["从"] = "世界"}, res) + +assert_equals("Ahoj Ahoj světe světe", ("Ahoj světe"):gsub("([^%p%s%c]+)", "%1 %1")) + +assert_equals("Ahoj Ahoj světe", ("Ahoj světe"):gsub("[^%p%s%c]+", "%0 %0", 1)) + +assert_equals("κόσμο γεια Lua από", ("γεια κόσμο από Lua"):gsub("([^%p%s%c]+)%s*([^%p%s%c]+)", "%2 %1")) + +assert_equals({8, 27, "ололоо я водитель э"}, {("пыщпыщ ололоо я водитель энло"):find("(.л.+)н")}) + +assert_equals({"пыщпыщ о보라보라 я водитель эн보라", 3}, {("пыщпыщ ололоо я водитель энло"):gsub("ло+", "보라")}) + +assert_equals("пыщпыщ ололоо я", ("пыщпыщ ололоо я водитель энло"):match("^п[лопыщ ]*я")) + +assert_equals("в", ("пыщпыщ ололоо я водитель энло"):match("[в-д]+")) + +assert_equals(nil, ('abc abc'):match('([^%s]+)%s%s')) -- https://github.com/Stepets/utf8.lua/issues/2 + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("a+b") do res[#res + 1] = w end +assert_equals({"ab","aab"}, res) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("a-b") do res[#res + 1] = w end +assert_equals({"ab","b","b","b","aab","b","b"}, res) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("a*b") do res[#res + 1] = w end +assert_equals({"ab","b","b","b","aab","b","b"}, res) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("ba+") do res[#res + 1] = w end +assert_equals({"ba","ba"}, res) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("ba-") do res[#res + 1] = w end +assert_equals({"b","b","b","b","b","b","b"}, res) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("ba*") do res[#res + 1] = w end +assert_equals({"b","ba","b","b","b","b","ba"}, res) + +assert_equals({"bacbbcaabbcba", "ba"}, {("aacabbacbbcaabbcbacaa"):match("((ba+).*%2)")}) +assert_equals({"bbacbbcaabbcb", "b"}, {("aacabbacbbcaabbcbacaa"):match("((ba*).*%2)")}) + +res = {} +for w in ("aacabbacbbcaabbcbacaa"):gmatch("((b+a*).-%2)") do res[#res + 1] = w end +assert_equals({"bbacbb", "bb"}, res) + +assert_equals("a**", ("a**v"):match("a**+")) +assert_equals("a", ("a**v"):match("a**-")) + +assert_equals({"test", "."}, {("test.lua"):match("(.-)([.])")}) + +-- https://github.com/Stepets/utf8.lua/issues/3 +assert_equals({"ab", "c"}, {("abc"):match("^([ab]-)([^b]*)$")}) +assert_equals({"ab", ""}, {("ab"):match("^([ab]-)([^b]*)$")}) +assert_equals({"items.", ""}, {("items."):match("^(.-)([^.]*)$")}) +assert_equals({"", "items"}, {("items"):match("^(.-)([^.]*)$")}) + +-- https://github.com/Stepets/utf8.lua/issues/4 +assert_equals({"ab.123", 1}, {("ab.?"):gsub("%?", "123")}) + +-- https://github.com/Stepets/utf8.lua/issues/5 +assert_equals({"ab", 1}, {("ab"):gsub("a", "%0")}) +assert_equals({"ab", 1}, {("ab"):gsub("a", "%1")}) + +assert_equals("c", ("abc"):match("c", -1)) + +print("\ntests passed\n") diff --git a/mac/.config/mpv/script-modules/utf8/test/test_compat.lua b/mac/.config/mpv/script-modules/utf8/test/test_compat.lua new file mode 100644 index 0000000..d5042a5 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/test_compat.lua @@ -0,0 +1,109 @@ +local utf8 = require 'init' +utf8.config = { + debug = nil, --utf8:require("util").debug +} +utf8:init() +print('testing utf8 library') + +local LUA_51, LUA_53 = false, false +if "\xe4" == "xe4" then -- lua5.1 + LUA_51 = true +else -- luajit lua5.3 + LUA_53 = true +end + +assert(utf8.sub("123456789",2,4) == "234") +assert(utf8.sub("123456789",7) == "789") +assert(utf8.sub("123456789",7,6) == "") +assert(utf8.sub("123456789",7,7) == "7") +assert(utf8.sub("123456789",0,0) == "") +assert(utf8.sub("123456789",-10,10) == "123456789") +assert(utf8.sub("123456789",1,9) == "123456789") +assert(utf8.sub("123456789",-10,-20) == "") +assert(utf8.sub("123456789",-1) == "9") +assert(utf8.sub("123456789",-4) == "6789") +assert(utf8.sub("123456789",-6, -4) == "456") +if not _no32 then + assert(utf8.sub("123456789",-2^31, -4) == "123456") + assert(utf8.sub("123456789",-2^31, 2^31 - 1) == "123456789") + assert(utf8.sub("123456789",-2^31, -2^31) == "") +end +assert(utf8.sub("\000123456789",3,5) == "234") +assert(utf8.sub("\000123456789", 8) == "789") +print('+') + +assert(utf8.find("123456789", "345") == 3) +local a,b = utf8.find("123456789", "345") +assert(utf8.sub("123456789", a, b) == "345") +assert(utf8.find("1234567890123456789", "345", 3) == 3) +assert(utf8.find("1234567890123456789", "345", 4) == 13) +assert(utf8.find("1234567890123456789", "346", 4) == nil) +assert(utf8.find("1234567890123456789", ".45", -9) == 13) +assert(utf8.find("abcdefg", "\0", 5, 1) == nil) +assert(utf8.find("", "") == 1) +assert(utf8.find("", "", 1) == 1) +assert(not utf8.find("", "", 2)) +assert(utf8.find('', 'aaa', 1) == nil) +assert(('alo(.)alo'):find('(.)', 1, 1) == 4) +print('+') + +assert(utf8.len("") == 0) +assert(utf8.len("\0\0\0") == 3) +assert(utf8.len("1234567890") == 10) + +assert(utf8.byte("a") == 97) +if LUA_51 then + assert(utf8.byte("�") > 127) +else + assert(utf8.byte("\xe4") > 127) +end +assert(utf8.byte(utf8.char(255)) == 255) +assert(utf8.byte(utf8.char(0)) == 0) +assert(utf8.byte("\0") == 0) +assert(utf8.byte("\0\0alo\0x", -1) == string.byte('x')) +assert(utf8.byte("ba", 2) == 97) +assert(utf8.byte("\n\n", 2, -1) == 10) +assert(utf8.byte("\n\n", 2, 2) == 10) +assert(utf8.byte("") == nil) +assert(utf8.byte("hi", -3) == nil) +assert(utf8.byte("hi", 3) == nil) +assert(utf8.byte("hi", 9, 10) == nil) +assert(utf8.byte("hi", 2, 1) == nil) +assert(utf8.char() == "") +if LUA_53 then + assert(utf8.raw.char(0, 255, 0) == "\0\255\0") -- fails due 255 can't be utf8 byte + assert(utf8.char(0, 255, 0) == "\0\195\191\0") + assert(utf8.raw.char(0, utf8.byte("\xe4"), 0) == "\0\xe4\0") + assert(utf8.char(0, utf8.byte("\xe4"), 0) == "\0\195\164\0") + assert(utf8.raw.char(utf8.raw.byte("\xe4l\0�u", 1, -1)) == "\xe4l\0�u") + assert(utf8.raw.char(utf8.raw.byte("\xe4l\0�u", 1, -1)) == "\xe4l\0�u") + assert(utf8.raw.char(utf8.raw.byte("\xe4l\0�u", 1, 0)) == "") + assert(utf8.raw.char(utf8.raw.byte("\xe4l\0�u", -10, 100)) == "\xe4l\0�u") +end + +assert(utf8.upper("ab\0c") == "AB\0C") +assert(utf8.lower("\0ABCc%$") == "\0abcc%$") +assert(utf8.rep('teste', 0) == '') +assert(utf8.rep('t�s\00t�', 2) == 't�s\0t�t�s\000t�') +assert(utf8.rep('', 10) == '') +print('+') + +assert(utf8.upper("ab\0c") == "AB\0C") +assert(utf8.lower("\0ABCc%$") == "\0abcc%$") + +assert(utf8.reverse"" == "") +assert(utf8.reverse"\0\1\2\3" == "\3\2\1\0") +assert(utf8.reverse"\0001234" == "4321\0") + +for i=0,30 do assert(utf8.len(string.rep('a', i)) == i) end + +print('+') + +do + local f = utf8.gmatch("1 2 3 4 5", "%d+") + assert(f() == "1") + local co = coroutine.wrap(f) + assert(co() == "2") +end + +print('OK') diff --git a/mac/.config/mpv/script-modules/utf8/test/test_pm.lua b/mac/.config/mpv/script-modules/utf8/test/test_pm.lua new file mode 100644 index 0000000..9c8e472 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/test_pm.lua @@ -0,0 +1,392 @@ +--[[-- +MIT License + +Copyright (c) 2018 Xavier Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--]]-- + +local utf8 = require 'init' +utf8.config = { + debug = nil, --utf8:require("util").debug, +} +utf8:init() + +print('testing pattern matching') + +local +function f(s, p) + local i,e = utf8.find(s, p) + if i then return utf8.sub(s, i, e) end +end + +local +function f1(s, p) + p = utf8.gsub(p, "%%([0-9])", function (s) return "%" .. (tonumber(s)+1) end) + p = utf8.gsub(p, "^(^?)", "%1()", 1) + p = utf8.gsub(p, "($?)$", "()%1", 1) + local t = {utf8.match(s, p)} + return utf8.sub(s, t[1], t[#t] - 1) +end + +local +a,b = utf8.find('', '') -- empty patterns are tricky +assert(a == 1 and b == 0); +a,b = utf8.find('alo', '') +assert(a == 1 and b == 0) +a,b = utf8.find('a\0o a\0o a\0o', 'a', 1) -- first position +assert(a == 1 and b == 1) +a,b = utf8.find('a\0o a\0o a\0o', 'a\0o', 2) -- starts in the midle +assert(a == 5 and b == 7) +a,b = utf8.find('a\0o a\0o a\0o', 'a\0o', 9) -- starts in the midle +assert(a == 9 and b == 11) +a,b = utf8.find('a\0a\0a\0a\0\0ab', '\0ab', 2); -- finds at the end +assert(a == 9 and b == 11); +a,b = utf8.find('a\0a\0a\0a\0\0ab', 'b') -- last position +assert(a == 11 and b == 11) +assert(utf8.find('a\0a\0a\0a\0\0ab', 'b\0') == nil) -- check ending +assert(utf8.find('', '\0') == nil) +assert(utf8.find('alo123alo', '12') == 4) +assert(utf8.find('alo123alo', '^12') == nil) + +assert(utf8.match("aaab", ".*b") == "aaab") +assert(utf8.match("aaa", ".*a") == "aaa") +assert(utf8.match("b", ".*b") == "b") + +assert(utf8.match("aaab", ".+b") == "aaab") +assert(utf8.match("aaa", ".+a") == "aaa") +assert(not utf8.match("b", ".+b")) + +assert(utf8.match("aaab", ".?b") == "ab") +assert(utf8.match("aaa", ".?a") == "aa") +assert(utf8.match("b", ".?b") == "b") + +assert(f('aloALO', '%l*') == 'alo') +assert(f('aLo_ALO', '%a*') == 'aLo') + +assert(f(" \n\r*&\n\r xuxu \n\n", "%g%g%g+") == "xuxu") + +assert(f('aaab', 'a*') == 'aaa'); +assert(f('aaa', '^.*$') == 'aaa'); +assert(f('aaa', 'b*') == ''); +assert(f('aaa', 'ab*a') == 'aa') +assert(f('aba', 'ab*a') == 'aba') +assert(f('aaab', 'a+') == 'aaa') +assert(f('aaa', '^.+$') == 'aaa') +assert(f('aaa', 'b+') == nil) +assert(f('aaa', 'ab+a') == nil) +assert(f('aba', 'ab+a') == 'aba') +assert(f('a$a', '.$') == 'a') +assert(f('a$a', '.%$') == 'a$') +assert(f('a$a', '.$.') == 'a$a') +assert(f('a$a', '$$') == nil) +assert(f('a$b', 'a$') == nil) +assert(f('a$a', '$') == '') +assert(f('', 'b*') == '') +assert(f('aaa', 'bb*') == nil) +assert(f('aaab', 'a-') == '') +assert(f('aaa', '^.-$') == 'aaa') +assert(f('aabaaabaaabaaaba', 'b.*b') == 'baaabaaabaaab') +assert(f('aabaaabaaabaaaba', 'b.-b') == 'baaab') +assert(f('alo xo', '.o$') == 'xo') +assert(f(' \n isto é assim', '%S%S*') == 'isto') +assert(f(' \n isto é assim', '%S*$') == 'assim') +assert(f(' \n isto é assim', '[a-z]*$') == 'assim') +assert(f('um caracter ? extra', '[^%sa-z]') == '?') +assert(f('', 'a?') == '') +assert(f('á', 'á?') == 'á') +assert(f('ábl', 'á?b?l?') == 'ábl') +assert(f(' ábl', 'á?b?l?') == '') +assert(f('aa', '^aa?a?a') == 'aa') +assert(f(']]]áb', '[^]]') == 'á') +assert(f("0alo alo", "%x*") == "0a") +assert(f("alo alo", "%C+") == "alo alo") +print('+') + +assert(f1('alo alx 123 b\0o b\0o', '(..*) %1') == "b\0o b\0o") +assert(f1('axz123= 4= 4 34', '(.+)=(.*)=%2 %1') == '3= 4= 4 3') +assert(f1('=======', '^(=*)=%1$') == '=======') +assert(utf8.match('==========', '^([=]*)=%1$') == nil) + +local function range (i, j) + if i <= j then + return i, range(i+1, j) + end +end + +local abc = utf8.char(range(0, 255)); + +assert(utf8.len(abc) == 256) +assert(string.len(abc) == 384) + +local +function strset (p) + local res = {s=''} + utf8.gsub(abc, p, function (c) res.s = res.s .. c end) + return res.s +end; + +local a, b, c, d, e, t + +-- local E = utf8.escape +-- assert(utf8.len(strset(E'[%200-%210]')) == 11) + +assert(strset('[a-z]') == "abcdefghijklmnopqrstuvwxyz") +assert(strset('[a-z%d]') == strset('[%da-uu-z]')) +assert(strset('[a-]') == "-a") +assert(strset('[^%W]') == strset('[%w]')) +assert(strset('[]%%]') == '%]') +assert(strset('[a%-z]') == '-az') +assert(strset('[%^%[%-a%]%-b]') == '-[]^ab') +-- assert(strset('%Z') == strset(E'[%1-%255]')) +-- assert(strset('.') == strset(E'[%1-%255%%z]')) +print('+'); + +assert(utf8.match("alo xyzK", "(%w+)K") == "xyz") +assert(utf8.match("254 K", "(%d*)K") == "") +assert(utf8.match("alo ", "(%w*)$") == "") +assert(utf8.match("alo ", "(%w+)$") == nil) +assert(utf8.find("(álo)", "%(á") == 1) +a, b, c, d, e = utf8.match("âlo alo", "^(((.).).* (%w*))$") +assert(a == 'âlo alo' and b == 'âl' and c == 'â' and d == 'alo' and e == nil) +a, b, c, d = utf8.match('0123456789', '(.+(.?)())') +assert(a == '0123456789' and b == '' and c == 11 and d == nil) +print('+') + +assert(utf8.gsub('ülo ülo', 'ü', 'x') == 'xlo xlo') +assert(utf8.gsub('alo úlo ', ' +$', '') == 'alo úlo') -- trim +assert(utf8.gsub(' alo alo ', '^%s*(.-)%s*$', '%1') == 'alo alo') -- double trim +assert(utf8.gsub('alo alo \n 123\n ', '%s+', ' ') == 'alo alo 123 ') +t = "abç d" +a, b = utf8.gsub(t, '(.)', '%1@') +assert('@'..a == utf8.gsub(t, '', '@') and b == 5) +a, b = utf8.gsub('abçd', '(.)', '%0@', 2) +assert(a == 'a@b@çd' and b == 2) +assert(utf8.gsub('alo alo', '()[al]', '%1') == '12o 56o') +assert(utf8.gsub("abc=xyz", "(%w*)(%p)(%w+)", "%3%2%1-%0") == + "xyz=abc-abc=xyz") +assert(utf8.gsub("abc", "%w", "%1%0") == "aabbcc") +assert(utf8.gsub("abc", "%w+", "%0%1") == "abcabc") +assert(utf8.gsub('áéí', '$', '\0óú') == 'áéí\0óú') +assert(utf8.gsub('', '^', 'r') == 'r') +assert(utf8.gsub('', '$', 'r') == 'r') +print('+') + +assert(utf8.gsub("um (dois) tres (quatro)", "(%(%w+%))", utf8.upper) == + "um (DOIS) tres (QUATRO)") + +do + local function setglobal (n,v) rawset(_G, n, v) end + utf8.gsub("a=roberto,roberto=a", "(%w+)=(%w%w*)", setglobal) + assert(_G.a=="roberto" and _G.roberto=="a") +end + +function f(a,b) return utf8.gsub(a,'.',b) end +assert(utf8.gsub("trocar tudo em |teste|b| é |beleza|al|", "|([^|]*)|([^|]*)|", f) == + "trocar tudo em bbbbb é alalalalalal") + +local function dostring (s) return (loadstring or load)(s)() or "" end +assert(utf8.gsub("alo $a=1$ novamente $return a$", "$([^$]*)%$", dostring) == + "alo novamente 1") + +x = utf8.gsub("$local utf8=require'init' x=utf8.gsub('alo', '.', utf8.upper)$ assim vai para $return x$", + "$([^$]*)%$", dostring) +assert(x == ' assim vai para ALO') + +local s,r +t = {} +s = 'a alo jose joao' +r = utf8.gsub(s, '()(%w+)()', function (a,w,b) + assert(utf8.len(w) == b-a); + t[a] = b-a; + end) +assert(s == r and t[1] == 1 and t[3] == 3 and t[7] == 4 and t[13] == 4) + +local +function isbalanced (s) + return utf8.find(utf8.gsub(s, "%b()", ""), "[()]") == nil +end + +assert(isbalanced("(9 ((8))(\0) 7) \0\0 a b ()(c)() a")) +assert(not isbalanced("(9 ((8) 7) a b (\0 c) a")) +assert(utf8.gsub("alo 'oi' alo", "%b''", '"') == 'alo " alo') + + +local t = {"apple", "orange", "lime"; n=0} +assert(utf8.gsub("x and x and x", "x", function () t.n=t.n+1; return t[t.n] end) + == "apple and orange and lime") + +t = {n=0} +utf8.gsub("first second word", "%w%w*", function (w) t.n=t.n+1; t[t.n] = w end) +assert(t[1] == "first" and t[2] == "second" and t[3] == "word" and t.n == 3) + +t = {n=0} +assert(utf8.gsub("first second word", "%w+", + function (w) t.n=t.n+1; t[t.n] = w end, 2) == "first second word") +assert(t[1] == "first" and t[2] == "second" and t[3] == nil) + +assert(not pcall(utf8.gsub, "alo", "(.", print)) +assert(not pcall(utf8.gsub, "alo", ".)", print)) +assert(not pcall(utf8.gsub, "alo", "(.", {})) +assert(not pcall(utf8.gsub, "alo", "(.)", "%2")) +assert(not pcall(utf8.gsub, "alo", "(%1)", "a")) +--[[-- +Stepets: ignoring this test because it's probably bug in Lua. + %0 should be interpreted as capture reference only in replacement arg + it doesn't have sense in pattern +--]]-- +-- assert(not pcall(utf8.gsub, "alo", "(%0)", "a")) + +-- bug since 2.5 (C-stack overflow) +-- todo: benchmark OOM +-- do +-- local function f (size) +-- local s = string.rep("a", size) +-- local p = string.rep(".?", size) +-- return pcall(utf8.match, s, p) +-- end +-- local r, m = f(80) +-- assert(r and #m == 80) +-- r, m = f(200000) +-- assert(not r and utf8.find(m, "too complex")) +-- end + +-- if not _soft then +-- -- big strings +-- local a = string.rep('a', 300000) +-- assert(utf8.find(a, '^a*.?$')) +-- assert(not utf8.find(a, '^a*.?b$')) +-- assert(utf8.find(a, '^a-.?$')) + +-- -- bug in 5.1.2 +-- a = string.rep('a', 10000) .. string.rep('b', 10000) +-- assert(not pcall(utf8.gsub, a, 'b')) +-- end + +-- recursive nest of gsubs +local function rev (s) + return utf8.gsub(s, "(.)(.+)", function (c,s1) return rev(s1)..c end) +end + +local x = "abcdef" +assert(rev(rev(x)) == x) + + +-- gsub with tables +assert(utf8.gsub("alo alo", ".", {}) == "alo alo") +assert(utf8.gsub("alo alo", "(.)", {a="AA", l=""}) == "AAo AAo") +assert(utf8.gsub("alo alo", "(.).", {a="AA", l="K"}) == "AAo AAo") +assert(utf8.gsub("alo alo", "((.)(.?))", {al="AA", o=false}) == "AAo AAo") + +assert(utf8.gsub("alo alo", "().", {2,5,6}) == "256 alo") + +t = {}; setmetatable(t, {__index = function (t,s) return utf8.upper(s) end}) +assert(utf8.gsub("a alo b hi", "%w%w+", t) == "a ALO b HI") + + +-- tests for gmatch +local a = 0 +for i in utf8.gmatch('abcde', '()') do assert(i == a+1); a=i end +assert(a==6) + +t = {n=0} +for w in utf8.gmatch("first second word", "%w+") do + t.n=t.n+1; t[t.n] = w +end +assert(t[1] == "first" and t[2] == "second" and t[3] == "word") + +t = {3, 6, 9} +for i in utf8.gmatch ("xuxx uu ppar r", "()(.)%2") do + assert(i == table.remove(t, 1)) +end +assert(#t == 0) + +t = {} +for i,j in utf8.gmatch("13 14 10 = 11, 15= 16, 22=23", "(%d+)%s*=%s*(%d+)") do + t[i] = j +end +a = 0 +for k,v in pairs(t) do assert(k+1 == v+0); a=a+1 end +assert(a == 3) + + +-- tests for `%f' (`frontiers') + +assert(utf8.gsub("aaa aa a aaa a", "%f[%w]a", "x") == "xaa xa x xaa x") +assert(utf8.gsub("[[]] [][] [[[[", "%f[[].", "x") == "x[]] x]x] x[[[") +assert(utf8.gsub("01abc45de3", "%f[%d]", ".") == ".01abc.45de.3") +assert(utf8.gsub("01abc45 de3x", "%f[%D]%w", ".") == "01.bc45 de3.") +-- local u = utf8.escape +-- assert(utf8.gsub("function", u"%%f[%1-%255]%%w", ".") == ".unction") +-- assert(utf8.gsub("function", u"%%f[^%1-%255]", ".") == "function.") + +--[[-- +Stepets: %z is Lua 5.1 class for representing \0 + Lua 5.2, Lua 5.3 doesn't have it in documentation. So it's considered deprecated. +--]]-- +assert(utf8.find("a", "%f[a]") == 1) +assert(utf8.find("a", "%f[^%z]") == 1) +assert(utf8.find("a", "%f[^%l]") == 2) +assert(utf8.find("aba", "%f[a%z]") == 3) +assert(utf8.find("aba", "%f[%z]") == 4) +assert(not utf8.find("aba", "%f[%l%z]")) +assert(not utf8.find("aba", "%f[^%l%z]")) + +local i, e = utf8.find(" alo aalo allo", "%f[%S].-%f[%s].-%f[%S]") +assert(i == 2 and e == 5) +local k = utf8.match(" alo aalo allo", "%f[%S](.-%f[%s].-%f[%S])") +assert(k == 'alo ') + +local a = {1, 5, 9, 14, 17,} +for k in utf8.gmatch("alo alo th02 is 1hat", "()%f[%w%d]") do + assert(table.remove(a, 1) == k) +end +assert(#a == 0) + +-- malformed patterns +local function malform (p, m) + m = m or "malformed" + local r, msg = pcall(utf8.find, "a", p) + assert(not r and utf8.find(msg, m)) +end + +malform("[a") +malform("[]") +malform("[^]") +malform("[a%]") +malform("[a%") +malform("%b", "unbalanced") +malform("%ba", "unbalanced") +malform("%") +malform("%f", "missing") + +-- \0 in patterns +assert(utf8.match("ab\0\1\2c", "[\0-\2]+") == "\0\1\2") +assert(utf8.match("ab\0\1\2c", "[\0-\0]+") == "\0") +assert(utf8.find("b$a", "$\0?") == 2) +assert(utf8.find("abc\0efg", "%\0") == 4) +assert(utf8.match("abc\0efg\0\1e\1g", "%b\0\1") == "\0efg\0\1e\1") +assert(utf8.match("abc\0\0\0", "%\0+") == "\0\0\0") +assert(utf8.match("abc\0\0\0", "%\0%\0?") == "\0\0") + +-- magic char after \0 +assert(utf8.find("abc\0\0","\0.") == 4) +assert(utf8.find("abcx\0\0abc\0abc","x\0\0abc\0a.") == 4) + +print('OK') diff --git a/mac/.config/mpv/script-modules/utf8/test/test_utf8data.lua b/mac/.config/mpv/script-modules/utf8/test/test_utf8data.lua new file mode 100644 index 0000000..e915b2b --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/test_utf8data.lua @@ -0,0 +1,15 @@ +local utf8uclc = require('init') +utf8uclc.config = { + debug = nil, +-- debug = utf8:require("util").debug, + conversion = { + uc_lc = setmetatable({}, {__index = function(self, idx) return "l" end}), + lc_uc = setmetatable({}, {__index = function(self, idx) return "u" end}), + } +} +utf8uclc:init() + +local assert_equals = require 'test.util'.assert_equals + +assert_equals(utf8uclc.lower("фыва"), "llll") +assert_equals(utf8uclc.upper("фыва"), "uuuu") diff --git a/mac/.config/mpv/script-modules/utf8/test/util.lua b/mac/.config/mpv/script-modules/utf8/test/util.lua new file mode 100644 index 0000000..bdc25e5 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/test/util.lua @@ -0,0 +1,75 @@ +require "test.strict" + +local function equals(t1, t2) + for k,v in pairs(t1) do + if t2[k] == nil then return false end + if type(t2[k]) == 'cdata' and type(v) == 'cdata' then + return true -- don't know how to compare + elseif type(t2[k]) == 'table' and type(v) == 'table' then + if not equals(t2[k], v) then return false end + else + if t2[k] ~= v then return false end + end + end + for k,v in pairs(t2) do + if t1[k] == nil then return false end + if type(t1[k]) == 'cdata' and type(v) == 'cdata' then + return true -- don't know how to compare + elseif type(t1[k]) == 'table' and type(v) == 'table' then + if not equals(t1[k], v) then return false end + else + if t1[k] ~= v then return false end + end + end + return true +end + +local old_tostring = tostring +local function tostring(v) + local type = type(v) + if type == 'table' then + local tbl = "{" + for k,v in pairs(v) do + tbl = tbl .. tostring(k) .. ' = ' .. tostring(v) .. ', ' + end + return tbl .. '}' + else + return old_tostring(v) + end +end + +local old_assert = assert +local assert = function(cond, ...) + if not cond then + local data = {...} + local msg = "" + for _, v in pairs(data) do + local type = type(v) + if type == 'table' then + local tbl = "{" + for k,v in pairs(v) do + tbl = tbl .. tostring(k) .. ' = ' .. tostring(v) .. ', ' + end + msg = msg .. tbl .. '}' + else + msg = msg .. tostring(v) + end + end + error(#data > 0 and msg or "assertion failed!") + end + return cond +end + +local function assert_equals(a,b) + assert( + type(a) == 'table' and type(b) == 'table' and equals(a,b) or a == b, + "expected: ", a and a or tostring(a), "\n", + "got: ", b and b or tostring(b) + ) +end + +return { + equals = equals, + assert = assert, + assert_equals = assert_equals, +} diff --git a/mac/.config/mpv/script-modules/utf8/util.lua b/mac/.config/mpv/script-modules/utf8/util.lua new file mode 100644 index 0000000..7723626 --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8/util.lua @@ -0,0 +1,64 @@ +return function(utf8) + +function utf8.util.copy(obj, deep) + if type(obj) == 'table' then + local result = {} + if deep then + for k,v in pairs(obj) do + result[k] = utf8.util.copy(v, true) + end + else + for k,v in pairs(obj) do + result[k] = v + end + end + return result + else + return obj + end +end + +local function dump(val, tab) + tab = tab or '' + + if type(val) == 'table' then + utf8.config.logger('{\n') + for k,v in pairs(val) do + utf8.config.logger(tab .. tostring(k) .. " = ") + dump(v, tab .. '\t') + utf8.config.logger("\n") + end + utf8.config.logger(tab .. '}\n') + else + utf8.config.logger(tostring(val)) + end +end + +function utf8.util.debug(...) + local t = {...} + for _, v in ipairs(t) do + if type(v) == "table" and not (getmetatable(v) or {}).__tostring then + dump(v, '\t') + else + utf8.config.logger(tostring(v), " ") + end + end + + utf8.config.logger('\n') +end + +function utf8.debug(...) + if utf8.config.debug then + utf8.config.debug(...) + end +end + +function utf8.util.next(str, bs) + local nbs1 = utf8.next(str, bs) + local nbs2 = utf8.next(str, nbs1) + return utf8.raw.sub(str, nbs1, nbs2 - 1), nbs1 +end + +return utf8.util + +end diff --git a/mac/.config/mpv/script-modules/utf8_data.lua b/mac/.config/mpv/script-modules/utf8_data.lua new file mode 100644 index 0000000..bec6b9e --- /dev/null +++ b/mac/.config/mpv/script-modules/utf8_data.lua @@ -0,0 +1,1865 @@ +utf8_lc_uc = { + ["a"] = "A", + ["b"] = "B", + ["c"] = "C", + ["d"] = "D", + ["e"] = "E", + ["f"] = "F", + ["g"] = "G", + ["h"] = "H", + ["i"] = "I", + ["j"] = "J", + ["k"] = "K", + ["l"] = "L", + ["m"] = "M", + ["n"] = "N", + ["o"] = "O", + ["p"] = "P", + ["q"] = "Q", + ["r"] = "R", + ["s"] = "S", + ["t"] = "T", + ["u"] = "U", + ["v"] = "V", + ["w"] = "W", + ["x"] = "X", + ["y"] = "Y", + ["z"] = "Z", + ["µ"] = "Μ", + ["à"] = "À", + ["á"] = "Á", + ["â"] = "Â", + ["ã"] = "Ã", + ["ä"] = "Ä", + ["å"] = "Å", + ["æ"] = "Æ", + ["ç"] = "Ç", + ["è"] = "È", + ["é"] = "É", + ["ê"] = "Ê", + ["ë"] = "Ë", + ["ì"] = "Ì", + ["í"] = "Í", + ["î"] = "Î", + ["ï"] = "Ï", + ["ð"] = "Ð", + ["ñ"] = "Ñ", + ["ò"] = "Ò", + ["ó"] = "Ó", + ["ô"] = "Ô", + ["õ"] = "Õ", + ["ö"] = "Ö", + ["ø"] = "Ø", + ["ù"] = "Ù", + ["ú"] = "Ú", + ["û"] = "Û", + ["ü"] = "Ü", + ["ý"] = "Ý", + ["þ"] = "Þ", + ["ÿ"] = "Ÿ", + ["ā"] = "Ā", + ["ă"] = "Ă", + ["ą"] = "Ą", + ["ć"] = "Ć", + ["ĉ"] = "Ĉ", + ["ċ"] = "Ċ", + ["č"] = "Č", + ["ď"] = "Ď", + ["đ"] = "Đ", + ["ē"] = "Ē", + ["ĕ"] = "Ĕ", + ["ė"] = "Ė", + ["ę"] = "Ę", + ["ě"] = "Ě", + ["ĝ"] = "Ĝ", + ["ğ"] = "Ğ", + ["ġ"] = "Ġ", + ["ģ"] = "Ģ", + ["ĥ"] = "Ĥ", + ["ħ"] = "Ħ", + ["ĩ"] = "Ĩ", + ["ī"] = "Ī", + ["ĭ"] = "Ĭ", + ["į"] = "Į", + ["ı"] = "I", + ["ij"] = "IJ", + ["ĵ"] = "Ĵ", + ["ķ"] = "Ķ", + ["ĺ"] = "Ĺ", + ["ļ"] = "Ļ", + ["ľ"] = "Ľ", + ["ŀ"] = "Ŀ", + ["ł"] = "Ł", + ["ń"] = "Ń", + ["ņ"] = "Ņ", + ["ň"] = "Ň", + ["ŋ"] = "Ŋ", + ["ō"] = "Ō", + ["ŏ"] = "Ŏ", + ["ő"] = "Ő", + ["œ"] = "Œ", + ["ŕ"] = "Ŕ", + ["ŗ"] = "Ŗ", + ["ř"] = "Ř", + ["ś"] = "Ś", + ["ŝ"] = "Ŝ", + ["ş"] = "Ş", + ["š"] = "Š", + ["ţ"] = "Ţ", + ["ť"] = "Ť", + ["ŧ"] = "Ŧ", + ["ũ"] = "Ũ", + ["ū"] = "Ū", + ["ŭ"] = "Ŭ", + ["ů"] = "Ů", + ["ű"] = "Ű", + ["ų"] = "Ų", + ["ŵ"] = "Ŵ", + ["ŷ"] = "Ŷ", + ["ź"] = "Ź", + ["ż"] = "Ż", + ["ž"] = "Ž", + ["ſ"] = "S", + ["ƀ"] = "Ƀ", + ["ƃ"] = "Ƃ", + ["ƅ"] = "Ƅ", + ["ƈ"] = "Ƈ", + ["ƌ"] = "Ƌ", + ["ƒ"] = "Ƒ", + ["ƕ"] = "Ƕ", + ["ƙ"] = "Ƙ", + ["ƚ"] = "Ƚ", + ["ƞ"] = "Ƞ", + ["ơ"] = "Ơ", + ["ƣ"] = "Ƣ", + ["ƥ"] = "Ƥ", + ["ƨ"] = "Ƨ", + ["ƭ"] = "Ƭ", + ["ư"] = "Ư", + ["ƴ"] = "Ƴ", + ["ƶ"] = "Ƶ", + ["ƹ"] = "Ƹ", + ["ƽ"] = "Ƽ", + ["ƿ"] = "Ƿ", + ["Dž"] = "DŽ", + ["dž"] = "DŽ", + ["Lj"] = "LJ", + ["lj"] = "LJ", + ["Nj"] = "NJ", + ["nj"] = "NJ", + ["ǎ"] = "Ǎ", + ["ǐ"] = "Ǐ", + ["ǒ"] = "Ǒ", + ["ǔ"] = "Ǔ", + ["ǖ"] = "Ǖ", + ["ǘ"] = "Ǘ", + ["ǚ"] = "Ǚ", + ["ǜ"] = "Ǜ", + ["ǝ"] = "Ǝ", + ["ǟ"] = "Ǟ", + ["ǡ"] = "Ǡ", + ["ǣ"] = "Ǣ", + ["ǥ"] = "Ǥ", + ["ǧ"] = "Ǧ", + ["ǩ"] = "Ǩ", + ["ǫ"] = "Ǫ", + ["ǭ"] = "Ǭ", + ["ǯ"] = "Ǯ", + ["Dz"] = "DZ", + ["dz"] = "DZ", + ["ǵ"] = "Ǵ", + ["ǹ"] = "Ǹ", + ["ǻ"] = "Ǻ", + ["ǽ"] = "Ǽ", + ["ǿ"] = "Ǿ", + ["ȁ"] = "Ȁ", + ["ȃ"] = "Ȃ", + ["ȅ"] = "Ȅ", + ["ȇ"] = "Ȇ", + ["ȉ"] = "Ȉ", + ["ȋ"] = "Ȋ", + ["ȍ"] = "Ȍ", + ["ȏ"] = "Ȏ", + ["ȑ"] = "Ȑ", + ["ȓ"] = "Ȓ", + ["ȕ"] = "Ȕ", + ["ȗ"] = "Ȗ", + ["ș"] = "Ș", + ["ț"] = "Ț", + ["ȝ"] = "Ȝ", + ["ȟ"] = "Ȟ", + ["ȣ"] = "Ȣ", + ["ȥ"] = "Ȥ", + ["ȧ"] = "Ȧ", + ["ȩ"] = "Ȩ", + ["ȫ"] = "Ȫ", + ["ȭ"] = "Ȭ", + ["ȯ"] = "Ȯ", + ["ȱ"] = "Ȱ", + ["ȳ"] = "Ȳ", + ["ȼ"] = "Ȼ", + ["ɂ"] = "Ɂ", + ["ɇ"] = "Ɇ", + ["ɉ"] = "Ɉ", + ["ɋ"] = "Ɋ", + ["ɍ"] = "Ɍ", + ["ɏ"] = "Ɏ", + ["ɓ"] = "Ɓ", + ["ɔ"] = "Ɔ", + ["ɖ"] = "Ɖ", + ["ɗ"] = "Ɗ", + ["ə"] = "Ə", + ["ɛ"] = "Ɛ", + ["ɠ"] = "Ɠ", + ["ɣ"] = "Ɣ", + ["ɨ"] = "Ɨ", + ["ɩ"] = "Ɩ", + ["ɫ"] = "Ɫ", + ["ɯ"] = "Ɯ", + ["ɲ"] = "Ɲ", + ["ɵ"] = "Ɵ", + ["ɽ"] = "Ɽ", + ["ʀ"] = "Ʀ", + ["ʃ"] = "Ʃ", + ["ʈ"] = "Ʈ", + ["ʉ"] = "Ʉ", + ["ʊ"] = "Ʊ", + ["ʋ"] = "Ʋ", + ["ʌ"] = "Ʌ", + ["ʒ"] = "Ʒ", + ["ͅ"] = "Ι", + ["ͻ"] = "Ͻ", + ["ͼ"] = "Ͼ", + ["ͽ"] = "Ͽ", + ["ά"] = "Ά", + ["έ"] = "Έ", + ["ή"] = "Ή", + ["ί"] = "Ί", + ["α"] = "Α", + ["β"] = "Β", + ["γ"] = "Γ", + ["δ"] = "Δ", + ["ε"] = "Ε", + ["ζ"] = "Ζ", + ["η"] = "Η", + ["θ"] = "Θ", + ["ι"] = "Ι", + ["κ"] = "Κ", + ["λ"] = "Λ", + ["μ"] = "Μ", + ["ν"] = "Ν", + ["ξ"] = "Ξ", + ["ο"] = "Ο", + ["π"] = "Π", + ["ρ"] = "Ρ", + ["ς"] = "Σ", + ["σ"] = "Σ", + ["τ"] = "Τ", + ["υ"] = "Υ", + ["φ"] = "Φ", + ["χ"] = "Χ", + ["ψ"] = "Ψ", + ["ω"] = "Ω", + ["ϊ"] = "Ϊ", + ["ϋ"] = "Ϋ", + ["ό"] = "Ό", + ["ύ"] = "Ύ", + ["ώ"] = "Ώ", + ["ϐ"] = "Β", + ["ϑ"] = "Θ", + ["ϕ"] = "Φ", + ["ϖ"] = "Π", + ["ϙ"] = "Ϙ", + ["ϛ"] = "Ϛ", + ["ϝ"] = "Ϝ", + ["ϟ"] = "Ϟ", + ["ϡ"] = "Ϡ", + ["ϣ"] = "Ϣ", + ["ϥ"] = "Ϥ", + ["ϧ"] = "Ϧ", + ["ϩ"] = "Ϩ", + ["ϫ"] = "Ϫ", + ["ϭ"] = "Ϭ", + ["ϯ"] = "Ϯ", + ["ϰ"] = "Κ", + ["ϱ"] = "Ρ", + ["ϲ"] = "Ϲ", + ["ϵ"] = "Ε", + ["ϸ"] = "Ϸ", + ["ϻ"] = "Ϻ", + ["а"] = "А", + ["б"] = "Б", + ["в"] = "В", + ["г"] = "Г", + ["д"] = "Д", + ["е"] = "Е", + ["ж"] = "Ж", + ["з"] = "З", + ["и"] = "И", + ["й"] = "Й", + ["к"] = "К", + ["л"] = "Л", + ["м"] = "М", + ["н"] = "Н", + ["о"] = "О", + ["п"] = "П", + ["р"] = "Р", + ["с"] = "С", + ["т"] = "Т", + ["у"] = "У", + ["ф"] = "Ф", + ["х"] = "Х", + ["ц"] = "Ц", + ["ч"] = "Ч", + ["ш"] = "Ш", + ["щ"] = "Щ", + ["ъ"] = "Ъ", + ["ы"] = "Ы", + ["ь"] = "Ь", + ["э"] = "Э", + ["ю"] = "Ю", + ["я"] = "Я", + ["ѐ"] = "Ѐ", + ["ё"] = "Ё", + ["ђ"] = "Ђ", + ["ѓ"] = "Ѓ", + ["є"] = "Є", + ["ѕ"] = "Ѕ", + ["і"] = "І", + ["ї"] = "Ї", + ["ј"] = "Ј", + ["љ"] = "Љ", + ["њ"] = "Њ", + ["ћ"] = "Ћ", + ["ќ"] = "Ќ", + ["ѝ"] = "Ѝ", + ["ў"] = "Ў", + ["џ"] = "Џ", + ["ѡ"] = "Ѡ", + ["ѣ"] = "Ѣ", + ["ѥ"] = "Ѥ", + ["ѧ"] = "Ѧ", + ["ѩ"] = "Ѩ", + ["ѫ"] = "Ѫ", + ["ѭ"] = "Ѭ", + ["ѯ"] = "Ѯ", + ["ѱ"] = "Ѱ", + ["ѳ"] = "Ѳ", + ["ѵ"] = "Ѵ", + ["ѷ"] = "Ѷ", + ["ѹ"] = "Ѹ", + ["ѻ"] = "Ѻ", + ["ѽ"] = "Ѽ", + ["ѿ"] = "Ѿ", + ["ҁ"] = "Ҁ", + ["ҋ"] = "Ҋ", + ["ҍ"] = "Ҍ", + ["ҏ"] = "Ҏ", + ["ґ"] = "Ґ", + ["ғ"] = "Ғ", + ["ҕ"] = "Ҕ", + ["җ"] = "Җ", + ["ҙ"] = "Ҙ", + ["қ"] = "Қ", + ["ҝ"] = "Ҝ", + ["ҟ"] = "Ҟ", + ["ҡ"] = "Ҡ", + ["ң"] = "Ң", + ["ҥ"] = "Ҥ", + ["ҧ"] = "Ҧ", + ["ҩ"] = "Ҩ", + ["ҫ"] = "Ҫ", + ["ҭ"] = "Ҭ", + ["ү"] = "Ү", + ["ұ"] = "Ұ", + ["ҳ"] = "Ҳ", + ["ҵ"] = "Ҵ", + ["ҷ"] = "Ҷ", + ["ҹ"] = "Ҹ", + ["һ"] = "Һ", + ["ҽ"] = "Ҽ", + ["ҿ"] = "Ҿ", + ["ӂ"] = "Ӂ", + ["ӄ"] = "Ӄ", + ["ӆ"] = "Ӆ", + ["ӈ"] = "Ӈ", + ["ӊ"] = "Ӊ", + ["ӌ"] = "Ӌ", + ["ӎ"] = "Ӎ", + ["ӏ"] = "Ӏ", + ["ӑ"] = "Ӑ", + ["ӓ"] = "Ӓ", + ["ӕ"] = "Ӕ", + ["ӗ"] = "Ӗ", + ["ә"] = "Ә", + ["ӛ"] = "Ӛ", + ["ӝ"] = "Ӝ", + ["ӟ"] = "Ӟ", + ["ӡ"] = "Ӡ", + ["ӣ"] = "Ӣ", + ["ӥ"] = "Ӥ", + ["ӧ"] = "Ӧ", + ["ө"] = "Ө", + ["ӫ"] = "Ӫ", + ["ӭ"] = "Ӭ", + ["ӯ"] = "Ӯ", + ["ӱ"] = "Ӱ", + ["ӳ"] = "Ӳ", + ["ӵ"] = "Ӵ", + ["ӷ"] = "Ӷ", + ["ӹ"] = "Ӹ", + ["ӻ"] = "Ӻ", + ["ӽ"] = "Ӽ", + ["ӿ"] = "Ӿ", + ["ԁ"] = "Ԁ", + ["ԃ"] = "Ԃ", + ["ԅ"] = "Ԅ", + ["ԇ"] = "Ԇ", + ["ԉ"] = "Ԉ", + ["ԋ"] = "Ԋ", + ["ԍ"] = "Ԍ", + ["ԏ"] = "Ԏ", + ["ԑ"] = "Ԑ", + ["ԓ"] = "Ԓ", + ["ա"] = "Ա", + ["բ"] = "Բ", + ["գ"] = "Գ", + ["դ"] = "Դ", + ["ե"] = "Ե", + ["զ"] = "Զ", + ["է"] = "Է", + ["ը"] = "Ը", + ["թ"] = "Թ", + ["ժ"] = "Ժ", + ["ի"] = "Ի", + ["լ"] = "Լ", + ["խ"] = "Խ", + ["ծ"] = "Ծ", + ["կ"] = "Կ", + ["հ"] = "Հ", + ["ձ"] = "Ձ", + ["ղ"] = "Ղ", + ["ճ"] = "Ճ", + ["մ"] = "Մ", + ["յ"] = "Յ", + ["ն"] = "Ն", + ["շ"] = "Շ", + ["ո"] = "Ո", + ["չ"] = "Չ", + ["պ"] = "Պ", + ["ջ"] = "Ջ", + ["ռ"] = "Ռ", + ["ս"] = "Ս", + ["վ"] = "Վ", + ["տ"] = "Տ", + ["ր"] = "Ր", + ["ց"] = "Ց", + ["ւ"] = "Ւ", + ["փ"] = "Փ", + ["ք"] = "Ք", + ["օ"] = "Օ", + ["ֆ"] = "Ֆ", + ["ᵽ"] = "Ᵽ", + ["ḁ"] = "Ḁ", + ["ḃ"] = "Ḃ", + ["ḅ"] = "Ḅ", + ["ḇ"] = "Ḇ", + ["ḉ"] = "Ḉ", + ["ḋ"] = "Ḋ", + ["ḍ"] = "Ḍ", + ["ḏ"] = "Ḏ", + ["ḑ"] = "Ḑ", + ["ḓ"] = "Ḓ", + ["ḕ"] = "Ḕ", + ["ḗ"] = "Ḗ", + ["ḙ"] = "Ḙ", + ["ḛ"] = "Ḛ", + ["ḝ"] = "Ḝ", + ["ḟ"] = "Ḟ", + ["ḡ"] = "Ḡ", + ["ḣ"] = "Ḣ", + ["ḥ"] = "Ḥ", + ["ḧ"] = "Ḧ", + ["ḩ"] = "Ḩ", + ["ḫ"] = "Ḫ", + ["ḭ"] = "Ḭ", + ["ḯ"] = "Ḯ", + ["ḱ"] = "Ḱ", + ["ḳ"] = "Ḳ", + ["ḵ"] = "Ḵ", + ["ḷ"] = "Ḷ", + ["ḹ"] = "Ḹ", + ["ḻ"] = "Ḻ", + ["ḽ"] = "Ḽ", + ["ḿ"] = "Ḿ", + ["ṁ"] = "Ṁ", + ["ṃ"] = "Ṃ", + ["ṅ"] = "Ṅ", + ["ṇ"] = "Ṇ", + ["ṉ"] = "Ṉ", + ["ṋ"] = "Ṋ", + ["ṍ"] = "Ṍ", + ["ṏ"] = "Ṏ", + ["ṑ"] = "Ṑ", + ["ṓ"] = "Ṓ", + ["ṕ"] = "Ṕ", + ["ṗ"] = "Ṗ", + ["ṙ"] = "Ṙ", + ["ṛ"] = "Ṛ", + ["ṝ"] = "Ṝ", + ["ṟ"] = "Ṟ", + ["ṡ"] = "Ṡ", + ["ṣ"] = "Ṣ", + ["ṥ"] = "Ṥ", + ["ṧ"] = "Ṧ", + ["ṩ"] = "Ṩ", + ["ṫ"] = "Ṫ", + ["ṭ"] = "Ṭ", + ["ṯ"] = "Ṯ", + ["ṱ"] = "Ṱ", + ["ṳ"] = "Ṳ", + ["ṵ"] = "Ṵ", + ["ṷ"] = "Ṷ", + ["ṹ"] = "Ṹ", + ["ṻ"] = "Ṻ", + ["ṽ"] = "Ṽ", + ["ṿ"] = "Ṿ", + ["ẁ"] = "Ẁ", + ["ẃ"] = "Ẃ", + ["ẅ"] = "Ẅ", + ["ẇ"] = "Ẇ", + ["ẉ"] = "Ẉ", + ["ẋ"] = "Ẋ", + ["ẍ"] = "Ẍ", + ["ẏ"] = "Ẏ", + ["ẑ"] = "Ẑ", + ["ẓ"] = "Ẓ", + ["ẕ"] = "Ẕ", + ["ẛ"] = "Ṡ", + ["ạ"] = "Ạ", + ["ả"] = "Ả", + ["ấ"] = "Ấ", + ["ầ"] = "Ầ", + ["ẩ"] = "Ẩ", + ["ẫ"] = "Ẫ", + ["ậ"] = "Ậ", + ["ắ"] = "Ắ", + ["ằ"] = "Ằ", + ["ẳ"] = "Ẳ", + ["ẵ"] = "Ẵ", + ["ặ"] = "Ặ", + ["ẹ"] = "Ẹ", + ["ẻ"] = "Ẻ", + ["ẽ"] = "Ẽ", + ["ế"] = "Ế", + ["ề"] = "Ề", + ["ể"] = "Ể", + ["ễ"] = "Ễ", + ["ệ"] = "Ệ", + ["ỉ"] = "Ỉ", + ["ị"] = "Ị", + ["ọ"] = "Ọ", + ["ỏ"] = "Ỏ", + ["ố"] = "Ố", + ["ồ"] = "Ồ", + ["ổ"] = "Ổ", + ["ỗ"] = "Ỗ", + ["ộ"] = "Ộ", + ["ớ"] = "Ớ", + ["ờ"] = "Ờ", + ["ở"] = "Ở", + ["ỡ"] = "Ỡ", + ["ợ"] = "Ợ", + ["ụ"] = "Ụ", + ["ủ"] = "Ủ", + ["ứ"] = "Ứ", + ["ừ"] = "Ừ", + ["ử"] = "Ử", + ["ữ"] = "Ữ", + ["ự"] = "Ự", + ["ỳ"] = "Ỳ", + ["ỵ"] = "Ỵ", + ["ỷ"] = "Ỷ", + ["ỹ"] = "Ỹ", + ["ἀ"] = "Ἀ", + ["ἁ"] = "Ἁ", + ["ἂ"] = "Ἂ", + ["ἃ"] = "Ἃ", + ["ἄ"] = "Ἄ", + ["ἅ"] = "Ἅ", + ["ἆ"] = "Ἆ", + ["ἇ"] = "Ἇ", + ["ἐ"] = "Ἐ", + ["ἑ"] = "Ἑ", + ["ἒ"] = "Ἒ", + ["ἓ"] = "Ἓ", + ["ἔ"] = "Ἔ", + ["ἕ"] = "Ἕ", + ["ἠ"] = "Ἠ", + ["ἡ"] = "Ἡ", + ["ἢ"] = "Ἢ", + ["ἣ"] = "Ἣ", + ["ἤ"] = "Ἤ", + ["ἥ"] = "Ἥ", + ["ἦ"] = "Ἦ", + ["ἧ"] = "Ἧ", + ["ἰ"] = "Ἰ", + ["ἱ"] = "Ἱ", + ["ἲ"] = "Ἲ", + ["ἳ"] = "Ἳ", + ["ἴ"] = "Ἴ", + ["ἵ"] = "Ἵ", + ["ἶ"] = "Ἶ", + ["ἷ"] = "Ἷ", + ["ὀ"] = "Ὀ", + ["ὁ"] = "Ὁ", + ["ὂ"] = "Ὂ", + ["ὃ"] = "Ὃ", + ["ὄ"] = "Ὄ", + ["ὅ"] = "Ὅ", + ["ὑ"] = "Ὑ", + ["ὓ"] = "Ὓ", + ["ὕ"] = "Ὕ", + ["ὗ"] = "Ὗ", + ["ὠ"] = "Ὠ", + ["ὡ"] = "Ὡ", + ["ὢ"] = "Ὢ", + ["ὣ"] = "Ὣ", + ["ὤ"] = "Ὤ", + ["ὥ"] = "Ὥ", + ["ὦ"] = "Ὦ", + ["ὧ"] = "Ὧ", + ["ὰ"] = "Ὰ", + ["ά"] = "Ά", + ["ὲ"] = "Ὲ", + ["έ"] = "Έ", + ["ὴ"] = "Ὴ", + ["ή"] = "Ή", + ["ὶ"] = "Ὶ", + ["ί"] = "Ί", + ["ὸ"] = "Ὸ", + ["ό"] = "Ό", + ["ὺ"] = "Ὺ", + ["ύ"] = "Ύ", + ["ὼ"] = "Ὼ", + ["ώ"] = "Ώ", + ["ᾀ"] = "ᾈ", + ["ᾁ"] = "ᾉ", + ["ᾂ"] = "ᾊ", + ["ᾃ"] = "ᾋ", + ["ᾄ"] = "ᾌ", + ["ᾅ"] = "ᾍ", + ["ᾆ"] = "ᾎ", + ["ᾇ"] = "ᾏ", + ["ᾐ"] = "ᾘ", + ["ᾑ"] = "ᾙ", + ["ᾒ"] = "ᾚ", + ["ᾓ"] = "ᾛ", + ["ᾔ"] = "ᾜ", + ["ᾕ"] = "ᾝ", + ["ᾖ"] = "ᾞ", + ["ᾗ"] = "ᾟ", + ["ᾠ"] = "ᾨ", + ["ᾡ"] = "ᾩ", + ["ᾢ"] = "ᾪ", + ["ᾣ"] = "ᾫ", + ["ᾤ"] = "ᾬ", + ["ᾥ"] = "ᾭ", + ["ᾦ"] = "ᾮ", + ["ᾧ"] = "ᾯ", + ["ᾰ"] = "Ᾰ", + ["ᾱ"] = "Ᾱ", + ["ᾳ"] = "ᾼ", + ["ι"] = "Ι", + ["ῃ"] = "ῌ", + ["ῐ"] = "Ῐ", + ["ῑ"] = "Ῑ", + ["ῠ"] = "Ῠ", + ["ῡ"] = "Ῡ", + ["ῥ"] = "Ῥ", + ["ῳ"] = "ῼ", + ["ⅎ"] = "Ⅎ", + ["ⅰ"] = "Ⅰ", + ["ⅱ"] = "Ⅱ", + ["ⅲ"] = "Ⅲ", + ["ⅳ"] = "Ⅳ", + ["ⅴ"] = "Ⅴ", + ["ⅵ"] = "Ⅵ", + ["ⅶ"] = "Ⅶ", + ["ⅷ"] = "Ⅷ", + ["ⅸ"] = "Ⅸ", + ["ⅹ"] = "Ⅹ", + ["ⅺ"] = "Ⅺ", + ["ⅻ"] = "Ⅻ", + ["ⅼ"] = "Ⅼ", + ["ⅽ"] = "Ⅽ", + ["ⅾ"] = "Ⅾ", + ["ⅿ"] = "Ⅿ", + ["ↄ"] = "Ↄ", + ["ⓐ"] = "Ⓐ", + ["ⓑ"] = "Ⓑ", + ["ⓒ"] = "Ⓒ", + ["ⓓ"] = "Ⓓ", + ["ⓔ"] = "Ⓔ", + ["ⓕ"] = "Ⓕ", + ["ⓖ"] = "Ⓖ", + ["ⓗ"] = "Ⓗ", + ["ⓘ"] = "Ⓘ", + ["ⓙ"] = "Ⓙ", + ["ⓚ"] = "Ⓚ", + ["ⓛ"] = "Ⓛ", + ["ⓜ"] = "Ⓜ", + ["ⓝ"] = "Ⓝ", + ["ⓞ"] = "Ⓞ", + ["ⓟ"] = "Ⓟ", + ["ⓠ"] = "Ⓠ", + ["ⓡ"] = "Ⓡ", + ["ⓢ"] = "Ⓢ", + ["ⓣ"] = "Ⓣ", + ["ⓤ"] = "Ⓤ", + ["ⓥ"] = "Ⓥ", + ["ⓦ"] = "Ⓦ", + ["ⓧ"] = "Ⓧ", + ["ⓨ"] = "Ⓨ", + ["ⓩ"] = "Ⓩ", + ["ⰰ"] = "Ⰰ", + ["ⰱ"] = "Ⰱ", + ["ⰲ"] = "Ⰲ", + ["ⰳ"] = "Ⰳ", + ["ⰴ"] = "Ⰴ", + ["ⰵ"] = "Ⰵ", + ["ⰶ"] = "Ⰶ", + ["ⰷ"] = "Ⰷ", + ["ⰸ"] = "Ⰸ", + ["ⰹ"] = "Ⰹ", + ["ⰺ"] = "Ⰺ", + ["ⰻ"] = "Ⰻ", + ["ⰼ"] = "Ⰼ", + ["ⰽ"] = "Ⰽ", + ["ⰾ"] = "Ⰾ", + ["ⰿ"] = "Ⰿ", + ["ⱀ"] = "Ⱀ", + ["ⱁ"] = "Ⱁ", + ["ⱂ"] = "Ⱂ", + ["ⱃ"] = "Ⱃ", + ["ⱄ"] = "Ⱄ", + ["ⱅ"] = "Ⱅ", + ["ⱆ"] = "Ⱆ", + ["ⱇ"] = "Ⱇ", + ["ⱈ"] = "Ⱈ", + ["ⱉ"] = "Ⱉ", + ["ⱊ"] = "Ⱊ", + ["ⱋ"] = "Ⱋ", + ["ⱌ"] = "Ⱌ", + ["ⱍ"] = "Ⱍ", + ["ⱎ"] = "Ⱎ", + ["ⱏ"] = "Ⱏ", + ["ⱐ"] = "Ⱐ", + ["ⱑ"] = "Ⱑ", + ["ⱒ"] = "Ⱒ", + ["ⱓ"] = "Ⱓ", + ["ⱔ"] = "Ⱔ", + ["ⱕ"] = "Ⱕ", + ["ⱖ"] = "Ⱖ", + ["ⱗ"] = "Ⱗ", + ["ⱘ"] = "Ⱘ", + ["ⱙ"] = "Ⱙ", + ["ⱚ"] = "Ⱚ", + ["ⱛ"] = "Ⱛ", + ["ⱜ"] = "Ⱜ", + ["ⱝ"] = "Ⱝ", + ["ⱞ"] = "Ⱞ", + ["ⱡ"] = "Ⱡ", + ["ⱥ"] = "Ⱥ", + ["ⱦ"] = "Ⱦ", + ["ⱨ"] = "Ⱨ", + ["ⱪ"] = "Ⱪ", + ["ⱬ"] = "Ⱬ", + ["ⱶ"] = "Ⱶ", + ["ⲁ"] = "Ⲁ", + ["ⲃ"] = "Ⲃ", + ["ⲅ"] = "Ⲅ", + ["ⲇ"] = "Ⲇ", + ["ⲉ"] = "Ⲉ", + ["ⲋ"] = "Ⲋ", + ["ⲍ"] = "Ⲍ", + ["ⲏ"] = "Ⲏ", + ["ⲑ"] = "Ⲑ", + ["ⲓ"] = "Ⲓ", + ["ⲕ"] = "Ⲕ", + ["ⲗ"] = "Ⲗ", + ["ⲙ"] = "Ⲙ", + ["ⲛ"] = "Ⲛ", + ["ⲝ"] = "Ⲝ", + ["ⲟ"] = "Ⲟ", + ["ⲡ"] = "Ⲡ", + ["ⲣ"] = "Ⲣ", + ["ⲥ"] = "Ⲥ", + ["ⲧ"] = "Ⲧ", + ["ⲩ"] = "Ⲩ", + ["ⲫ"] = "Ⲫ", + ["ⲭ"] = "Ⲭ", + ["ⲯ"] = "Ⲯ", + ["ⲱ"] = "Ⲱ", + ["ⲳ"] = "Ⲳ", + ["ⲵ"] = "Ⲵ", + ["ⲷ"] = "Ⲷ", + ["ⲹ"] = "Ⲹ", + ["ⲻ"] = "Ⲻ", + ["ⲽ"] = "Ⲽ", + ["ⲿ"] = "Ⲿ", + ["ⳁ"] = "Ⳁ", + ["ⳃ"] = "Ⳃ", + ["ⳅ"] = "Ⳅ", + ["ⳇ"] = "Ⳇ", + ["ⳉ"] = "Ⳉ", + ["ⳋ"] = "Ⳋ", + ["ⳍ"] = "Ⳍ", + ["ⳏ"] = "Ⳏ", + ["ⳑ"] = "Ⳑ", + ["ⳓ"] = "Ⳓ", + ["ⳕ"] = "Ⳕ", + ["ⳗ"] = "Ⳗ", + ["ⳙ"] = "Ⳙ", + ["ⳛ"] = "Ⳛ", + ["ⳝ"] = "Ⳝ", + ["ⳟ"] = "Ⳟ", + ["ⳡ"] = "Ⳡ", + ["ⳣ"] = "Ⳣ", + ["ⴀ"] = "Ⴀ", + ["ⴁ"] = "Ⴁ", + ["ⴂ"] = "Ⴂ", + ["ⴃ"] = "Ⴃ", + ["ⴄ"] = "Ⴄ", + ["ⴅ"] = "Ⴅ", + ["ⴆ"] = "Ⴆ", + ["ⴇ"] = "Ⴇ", + ["ⴈ"] = "Ⴈ", + ["ⴉ"] = "Ⴉ", + ["ⴊ"] = "Ⴊ", + ["ⴋ"] = "Ⴋ", + ["ⴌ"] = "Ⴌ", + ["ⴍ"] = "Ⴍ", + ["ⴎ"] = "Ⴎ", + ["ⴏ"] = "Ⴏ", + ["ⴐ"] = "Ⴐ", + ["ⴑ"] = "Ⴑ", + ["ⴒ"] = "Ⴒ", + ["ⴓ"] = "Ⴓ", + ["ⴔ"] = "Ⴔ", + ["ⴕ"] = "Ⴕ", + ["ⴖ"] = "Ⴖ", + ["ⴗ"] = "Ⴗ", + ["ⴘ"] = "Ⴘ", + ["ⴙ"] = "Ⴙ", + ["ⴚ"] = "Ⴚ", + ["ⴛ"] = "Ⴛ", + ["ⴜ"] = "Ⴜ", + ["ⴝ"] = "Ⴝ", + ["ⴞ"] = "Ⴞ", + ["ⴟ"] = "Ⴟ", + ["ⴠ"] = "Ⴠ", + ["ⴡ"] = "Ⴡ", + ["ⴢ"] = "Ⴢ", + ["ⴣ"] = "Ⴣ", + ["ⴤ"] = "Ⴤ", + ["ⴥ"] = "Ⴥ", + ["a"] = "A", + ["b"] = "B", + ["c"] = "C", + ["d"] = "D", + ["e"] = "E", + ["f"] = "F", + ["g"] = "G", + ["h"] = "H", + ["i"] = "I", + ["j"] = "J", + ["k"] = "K", + ["l"] = "L", + ["m"] = "M", + ["n"] = "N", + ["o"] = "O", + ["p"] = "P", + ["q"] = "Q", + ["r"] = "R", + ["s"] = "S", + ["t"] = "T", + ["u"] = "U", + ["v"] = "V", + ["w"] = "W", + ["x"] = "X", + ["y"] = "Y", + ["z"] = "Z", + ["𐐨"] = "𐐀", + ["𐐩"] = "𐐁", + ["𐐪"] = "𐐂", + ["𐐫"] = "𐐃", + ["𐐬"] = "𐐄", + ["𐐭"] = "𐐅", + ["𐐮"] = "𐐆", + ["𐐯"] = "𐐇", + ["𐐰"] = "𐐈", + ["𐐱"] = "𐐉", + ["𐐲"] = "𐐊", + ["𐐳"] = "𐐋", + ["𐐴"] = "𐐌", + ["𐐵"] = "𐐍", + ["𐐶"] = "𐐎", + ["𐐷"] = "𐐏", + ["𐐸"] = "𐐐", + ["𐐹"] = "𐐑", + ["𐐺"] = "𐐒", + ["𐐻"] = "𐐓", + ["𐐼"] = "𐐔", + ["𐐽"] = "𐐕", + ["𐐾"] = "𐐖", + ["𐐿"] = "𐐗", + ["𐑀"] = "𐐘", + ["𐑁"] = "𐐙", + ["𐑂"] = "𐐚", + ["𐑃"] = "𐐛", + ["𐑄"] = "𐐜", + ["𐑅"] = "𐐝", + ["𐑆"] = "𐐞", + ["𐑇"] = "𐐟", + ["𐑈"] = "𐐠", + ["𐑉"] = "𐐡", + ["𐑊"] = "𐐢", + ["𐑋"] = "𐐣", + ["𐑌"] = "𐐤", + ["𐑍"] = "𐐥", + ["𐑎"] = "𐐦", + ["𐑏"] = "𐐧", +} + + +utf8_uc_lc = { + ["A"] = "a", + ["B"] = "b", + ["C"] = "c", + ["D"] = "d", + ["E"] = "e", + ["F"] = "f", + ["G"] = "g", + ["H"] = "h", + ["I"] = "i", + ["J"] = "j", + ["K"] = "k", + ["L"] = "l", + ["M"] = "m", + ["N"] = "n", + ["O"] = "o", + ["P"] = "p", + ["Q"] = "q", + ["R"] = "r", + ["S"] = "s", + ["T"] = "t", + ["U"] = "u", + ["V"] = "v", + ["W"] = "w", + ["X"] = "x", + ["Y"] = "y", + ["Z"] = "z", + ["À"] = "à", + ["Á"] = "á", + ["Â"] = "â", + ["Ã"] = "ã", + ["Ä"] = "ä", + ["Å"] = "å", + ["Æ"] = "æ", + ["Ç"] = "ç", + ["È"] = "è", + ["É"] = "é", + ["Ê"] = "ê", + ["Ë"] = "ë", + ["Ì"] = "ì", + ["Í"] = "í", + ["Î"] = "î", + ["Ï"] = "ï", + ["Ð"] = "ð", + ["Ñ"] = "ñ", + ["Ò"] = "ò", + ["Ó"] = "ó", + ["Ô"] = "ô", + ["Õ"] = "õ", + ["Ö"] = "ö", + ["Ø"] = "ø", + ["Ù"] = "ù", + ["Ú"] = "ú", + ["Û"] = "û", + ["Ü"] = "ü", + ["Ý"] = "ý", + ["Þ"] = "þ", + ["Ā"] = "ā", + ["Ă"] = "ă", + ["Ą"] = "ą", + ["Ć"] = "ć", + ["Ĉ"] = "ĉ", + ["Ċ"] = "ċ", + ["Č"] = "č", + ["Ď"] = "ď", + ["Đ"] = "đ", + ["Ē"] = "ē", + ["Ĕ"] = "ĕ", + ["Ė"] = "ė", + ["Ę"] = "ę", + ["Ě"] = "ě", + ["Ĝ"] = "ĝ", + ["Ğ"] = "ğ", + ["Ġ"] = "ġ", + ["Ģ"] = "ģ", + ["Ĥ"] = "ĥ", + ["Ħ"] = "ħ", + ["Ĩ"] = "ĩ", + ["Ī"] = "ī", + ["Ĭ"] = "ĭ", + ["Į"] = "į", + ["İ"] = "i", + ["IJ"] = "ij", + ["Ĵ"] = "ĵ", + ["Ķ"] = "ķ", + ["Ĺ"] = "ĺ", + ["Ļ"] = "ļ", + ["Ľ"] = "ľ", + ["Ŀ"] = "ŀ", + ["Ł"] = "ł", + ["Ń"] = "ń", + ["Ņ"] = "ņ", + ["Ň"] = "ň", + ["Ŋ"] = "ŋ", + ["Ō"] = "ō", + ["Ŏ"] = "ŏ", + ["Ő"] = "ő", + ["Œ"] = "œ", + ["Ŕ"] = "ŕ", + ["Ŗ"] = "ŗ", + ["Ř"] = "ř", + ["Ś"] = "ś", + ["Ŝ"] = "ŝ", + ["Ş"] = "ş", + ["Š"] = "š", + ["Ţ"] = "ţ", + ["Ť"] = "ť", + ["Ŧ"] = "ŧ", + ["Ũ"] = "ũ", + ["Ū"] = "ū", + ["Ŭ"] = "ŭ", + ["Ů"] = "ů", + ["Ű"] = "ű", + ["Ų"] = "ų", + ["Ŵ"] = "ŵ", + ["Ŷ"] = "ŷ", + ["Ÿ"] = "ÿ", + ["Ź"] = "ź", + ["Ż"] = "ż", + ["Ž"] = "ž", + ["Ɓ"] = "ɓ", + ["Ƃ"] = "ƃ", + ["Ƅ"] = "ƅ", + ["Ɔ"] = "ɔ", + ["Ƈ"] = "ƈ", + ["Ɖ"] = "ɖ", + ["Ɗ"] = "ɗ", + ["Ƌ"] = "ƌ", + ["Ǝ"] = "ǝ", + ["Ə"] = "ə", + ["Ɛ"] = "ɛ", + ["Ƒ"] = "ƒ", + ["Ɠ"] = "ɠ", + ["Ɣ"] = "ɣ", + ["Ɩ"] = "ɩ", + ["Ɨ"] = "ɨ", + ["Ƙ"] = "ƙ", + ["Ɯ"] = "ɯ", + ["Ɲ"] = "ɲ", + ["Ɵ"] = "ɵ", + ["Ơ"] = "ơ", + ["Ƣ"] = "ƣ", + ["Ƥ"] = "ƥ", + ["Ʀ"] = "ʀ", + ["Ƨ"] = "ƨ", + ["Ʃ"] = "ʃ", + ["Ƭ"] = "ƭ", + ["Ʈ"] = "ʈ", + ["Ư"] = "ư", + ["Ʊ"] = "ʊ", + ["Ʋ"] = "ʋ", + ["Ƴ"] = "ƴ", + ["Ƶ"] = "ƶ", + ["Ʒ"] = "ʒ", + ["Ƹ"] = "ƹ", + ["Ƽ"] = "ƽ", + ["DŽ"] = "dž", + ["Dž"] = "dž", + ["LJ"] = "lj", + ["Lj"] = "lj", + ["NJ"] = "nj", + ["Nj"] = "nj", + ["Ǎ"] = "ǎ", + ["Ǐ"] = "ǐ", + ["Ǒ"] = "ǒ", + ["Ǔ"] = "ǔ", + ["Ǖ"] = "ǖ", + ["Ǘ"] = "ǘ", + ["Ǚ"] = "ǚ", + ["Ǜ"] = "ǜ", + ["Ǟ"] = "ǟ", + ["Ǡ"] = "ǡ", + ["Ǣ"] = "ǣ", + ["Ǥ"] = "ǥ", + ["Ǧ"] = "ǧ", + ["Ǩ"] = "ǩ", + ["Ǫ"] = "ǫ", + ["Ǭ"] = "ǭ", + ["Ǯ"] = "ǯ", + ["DZ"] = "dz", + ["Dz"] = "dz", + ["Ǵ"] = "ǵ", + ["Ƕ"] = "ƕ", + ["Ƿ"] = "ƿ", + ["Ǹ"] = "ǹ", + ["Ǻ"] = "ǻ", + ["Ǽ"] = "ǽ", + ["Ǿ"] = "ǿ", + ["Ȁ"] = "ȁ", + ["Ȃ"] = "ȃ", + ["Ȅ"] = "ȅ", + ["Ȇ"] = "ȇ", + ["Ȉ"] = "ȉ", + ["Ȋ"] = "ȋ", + ["Ȍ"] = "ȍ", + ["Ȏ"] = "ȏ", + ["Ȑ"] = "ȑ", + ["Ȓ"] = "ȓ", + ["Ȕ"] = "ȕ", + ["Ȗ"] = "ȗ", + ["Ș"] = "ș", + ["Ț"] = "ț", + ["Ȝ"] = "ȝ", + ["Ȟ"] = "ȟ", + ["Ƞ"] = "ƞ", + ["Ȣ"] = "ȣ", + ["Ȥ"] = "ȥ", + ["Ȧ"] = "ȧ", + ["Ȩ"] = "ȩ", + ["Ȫ"] = "ȫ", + ["Ȭ"] = "ȭ", + ["Ȯ"] = "ȯ", + ["Ȱ"] = "ȱ", + ["Ȳ"] = "ȳ", + ["Ⱥ"] = "ⱥ", + ["Ȼ"] = "ȼ", + ["Ƚ"] = "ƚ", + ["Ⱦ"] = "ⱦ", + ["Ɂ"] = "ɂ", + ["Ƀ"] = "ƀ", + ["Ʉ"] = "ʉ", + ["Ʌ"] = "ʌ", + ["Ɇ"] = "ɇ", + ["Ɉ"] = "ɉ", + ["Ɋ"] = "ɋ", + ["Ɍ"] = "ɍ", + ["Ɏ"] = "ɏ", + ["Ά"] = "ά", + ["Έ"] = "έ", + ["Ή"] = "ή", + ["Ί"] = "ί", + ["Ό"] = "ό", + ["Ύ"] = "ύ", + ["Ώ"] = "ώ", + ["Α"] = "α", + ["Β"] = "β", + ["Γ"] = "γ", + ["Δ"] = "δ", + ["Ε"] = "ε", + ["Ζ"] = "ζ", + ["Η"] = "η", + ["Θ"] = "θ", + ["Ι"] = "ι", + ["Κ"] = "κ", + ["Λ"] = "λ", + ["Μ"] = "μ", + ["Ν"] = "ν", + ["Ξ"] = "ξ", + ["Ο"] = "ο", + ["Π"] = "π", + ["Ρ"] = "ρ", + ["Σ"] = "σ", + ["Τ"] = "τ", + ["Υ"] = "υ", + ["Φ"] = "φ", + ["Χ"] = "χ", + ["Ψ"] = "ψ", + ["Ω"] = "ω", + ["Ϊ"] = "ϊ", + ["Ϋ"] = "ϋ", + ["Ϙ"] = "ϙ", + ["Ϛ"] = "ϛ", + ["Ϝ"] = "ϝ", + ["Ϟ"] = "ϟ", + ["Ϡ"] = "ϡ", + ["Ϣ"] = "ϣ", + ["Ϥ"] = "ϥ", + ["Ϧ"] = "ϧ", + ["Ϩ"] = "ϩ", + ["Ϫ"] = "ϫ", + ["Ϭ"] = "ϭ", + ["Ϯ"] = "ϯ", + ["ϴ"] = "θ", + ["Ϸ"] = "ϸ", + ["Ϲ"] = "ϲ", + ["Ϻ"] = "ϻ", + ["Ͻ"] = "ͻ", + ["Ͼ"] = "ͼ", + ["Ͽ"] = "ͽ", + ["Ѐ"] = "ѐ", + ["Ё"] = "ё", + ["Ђ"] = "ђ", + ["Ѓ"] = "ѓ", + ["Є"] = "є", + ["Ѕ"] = "ѕ", + ["І"] = "і", + ["Ї"] = "ї", + ["Ј"] = "ј", + ["Љ"] = "љ", + ["Њ"] = "њ", + ["Ћ"] = "ћ", + ["Ќ"] = "ќ", + ["Ѝ"] = "ѝ", + ["Ў"] = "ў", + ["Џ"] = "џ", + ["А"] = "а", + ["Б"] = "б", + ["В"] = "в", + ["Г"] = "г", + ["Д"] = "д", + ["Е"] = "е", + ["Ж"] = "ж", + ["З"] = "з", + ["И"] = "и", + ["Й"] = "й", + ["К"] = "к", + ["Л"] = "л", + ["М"] = "м", + ["Н"] = "н", + ["О"] = "о", + ["П"] = "п", + ["Р"] = "р", + ["С"] = "с", + ["Т"] = "т", + ["У"] = "у", + ["Ф"] = "ф", + ["Х"] = "х", + ["Ц"] = "ц", + ["Ч"] = "ч", + ["Ш"] = "ш", + ["Щ"] = "щ", + ["Ъ"] = "ъ", + ["Ы"] = "ы", + ["Ь"] = "ь", + ["Э"] = "э", + ["Ю"] = "ю", + ["Я"] = "я", + ["Ѡ"] = "ѡ", + ["Ѣ"] = "ѣ", + ["Ѥ"] = "ѥ", + ["Ѧ"] = "ѧ", + ["Ѩ"] = "ѩ", + ["Ѫ"] = "ѫ", + ["Ѭ"] = "ѭ", + ["Ѯ"] = "ѯ", + ["Ѱ"] = "ѱ", + ["Ѳ"] = "ѳ", + ["Ѵ"] = "ѵ", + ["Ѷ"] = "ѷ", + ["Ѹ"] = "ѹ", + ["Ѻ"] = "ѻ", + ["Ѽ"] = "ѽ", + ["Ѿ"] = "ѿ", + ["Ҁ"] = "ҁ", + ["Ҋ"] = "ҋ", + ["Ҍ"] = "ҍ", + ["Ҏ"] = "ҏ", + ["Ґ"] = "ґ", + ["Ғ"] = "ғ", + ["Ҕ"] = "ҕ", + ["Җ"] = "җ", + ["Ҙ"] = "ҙ", + ["Қ"] = "қ", + ["Ҝ"] = "ҝ", + ["Ҟ"] = "ҟ", + ["Ҡ"] = "ҡ", + ["Ң"] = "ң", + ["Ҥ"] = "ҥ", + ["Ҧ"] = "ҧ", + ["Ҩ"] = "ҩ", + ["Ҫ"] = "ҫ", + ["Ҭ"] = "ҭ", + ["Ү"] = "ү", + ["Ұ"] = "ұ", + ["Ҳ"] = "ҳ", + ["Ҵ"] = "ҵ", + ["Ҷ"] = "ҷ", + ["Ҹ"] = "ҹ", + ["Һ"] = "һ", + ["Ҽ"] = "ҽ", + ["Ҿ"] = "ҿ", + ["Ӏ"] = "ӏ", + ["Ӂ"] = "ӂ", + ["Ӄ"] = "ӄ", + ["Ӆ"] = "ӆ", + ["Ӈ"] = "ӈ", + ["Ӊ"] = "ӊ", + ["Ӌ"] = "ӌ", + ["Ӎ"] = "ӎ", + ["Ӑ"] = "ӑ", + ["Ӓ"] = "ӓ", + ["Ӕ"] = "ӕ", + ["Ӗ"] = "ӗ", + ["Ә"] = "ә", + ["Ӛ"] = "ӛ", + ["Ӝ"] = "ӝ", + ["Ӟ"] = "ӟ", + ["Ӡ"] = "ӡ", + ["Ӣ"] = "ӣ", + ["Ӥ"] = "ӥ", + ["Ӧ"] = "ӧ", + ["Ө"] = "ө", + ["Ӫ"] = "ӫ", + ["Ӭ"] = "ӭ", + ["Ӯ"] = "ӯ", + ["Ӱ"] = "ӱ", + ["Ӳ"] = "ӳ", + ["Ӵ"] = "ӵ", + ["Ӷ"] = "ӷ", + ["Ӹ"] = "ӹ", + ["Ӻ"] = "ӻ", + ["Ӽ"] = "ӽ", + ["Ӿ"] = "ӿ", + ["Ԁ"] = "ԁ", + ["Ԃ"] = "ԃ", + ["Ԅ"] = "ԅ", + ["Ԇ"] = "ԇ", + ["Ԉ"] = "ԉ", + ["Ԋ"] = "ԋ", + ["Ԍ"] = "ԍ", + ["Ԏ"] = "ԏ", + ["Ԑ"] = "ԑ", + ["Ԓ"] = "ԓ", + ["Ա"] = "ա", + ["Բ"] = "բ", + ["Գ"] = "գ", + ["Դ"] = "դ", + ["Ե"] = "ե", + ["Զ"] = "զ", + ["Է"] = "է", + ["Ը"] = "ը", + ["Թ"] = "թ", + ["Ժ"] = "ժ", + ["Ի"] = "ի", + ["Լ"] = "լ", + ["Խ"] = "խ", + ["Ծ"] = "ծ", + ["Կ"] = "կ", + ["Հ"] = "հ", + ["Ձ"] = "ձ", + ["Ղ"] = "ղ", + ["Ճ"] = "ճ", + ["Մ"] = "մ", + ["Յ"] = "յ", + ["Ն"] = "ն", + ["Շ"] = "շ", + ["Ո"] = "ո", + ["Չ"] = "չ", + ["Պ"] = "պ", + ["Ջ"] = "ջ", + ["Ռ"] = "ռ", + ["Ս"] = "ս", + ["Վ"] = "վ", + ["Տ"] = "տ", + ["Ր"] = "ր", + ["Ց"] = "ց", + ["Ւ"] = "ւ", + ["Փ"] = "փ", + ["Ք"] = "ք", + ["Օ"] = "օ", + ["Ֆ"] = "ֆ", + ["Ⴀ"] = "ⴀ", + ["Ⴁ"] = "ⴁ", + ["Ⴂ"] = "ⴂ", + ["Ⴃ"] = "ⴃ", + ["Ⴄ"] = "ⴄ", + ["Ⴅ"] = "ⴅ", + ["Ⴆ"] = "ⴆ", + ["Ⴇ"] = "ⴇ", + ["Ⴈ"] = "ⴈ", + ["Ⴉ"] = "ⴉ", + ["Ⴊ"] = "ⴊ", + ["Ⴋ"] = "ⴋ", + ["Ⴌ"] = "ⴌ", + ["Ⴍ"] = "ⴍ", + ["Ⴎ"] = "ⴎ", + ["Ⴏ"] = "ⴏ", + ["Ⴐ"] = "ⴐ", + ["Ⴑ"] = "ⴑ", + ["Ⴒ"] = "ⴒ", + ["Ⴓ"] = "ⴓ", + ["Ⴔ"] = "ⴔ", + ["Ⴕ"] = "ⴕ", + ["Ⴖ"] = "ⴖ", + ["Ⴗ"] = "ⴗ", + ["Ⴘ"] = "ⴘ", + ["Ⴙ"] = "ⴙ", + ["Ⴚ"] = "ⴚ", + ["Ⴛ"] = "ⴛ", + ["Ⴜ"] = "ⴜ", + ["Ⴝ"] = "ⴝ", + ["Ⴞ"] = "ⴞ", + ["Ⴟ"] = "ⴟ", + ["Ⴠ"] = "ⴠ", + ["Ⴡ"] = "ⴡ", + ["Ⴢ"] = "ⴢ", + ["Ⴣ"] = "ⴣ", + ["Ⴤ"] = "ⴤ", + ["Ⴥ"] = "ⴥ", + ["Ḁ"] = "ḁ", + ["Ḃ"] = "ḃ", + ["Ḅ"] = "ḅ", + ["Ḇ"] = "ḇ", + ["Ḉ"] = "ḉ", + ["Ḋ"] = "ḋ", + ["Ḍ"] = "ḍ", + ["Ḏ"] = "ḏ", + ["Ḑ"] = "ḑ", + ["Ḓ"] = "ḓ", + ["Ḕ"] = "ḕ", + ["Ḗ"] = "ḗ", + ["Ḙ"] = "ḙ", + ["Ḛ"] = "ḛ", + ["Ḝ"] = "ḝ", + ["Ḟ"] = "ḟ", + ["Ḡ"] = "ḡ", + ["Ḣ"] = "ḣ", + ["Ḥ"] = "ḥ", + ["Ḧ"] = "ḧ", + ["Ḩ"] = "ḩ", + ["Ḫ"] = "ḫ", + ["Ḭ"] = "ḭ", + ["Ḯ"] = "ḯ", + ["Ḱ"] = "ḱ", + ["Ḳ"] = "ḳ", + ["Ḵ"] = "ḵ", + ["Ḷ"] = "ḷ", + ["Ḹ"] = "ḹ", + ["Ḻ"] = "ḻ", + ["Ḽ"] = "ḽ", + ["Ḿ"] = "ḿ", + ["Ṁ"] = "ṁ", + ["Ṃ"] = "ṃ", + ["Ṅ"] = "ṅ", + ["Ṇ"] = "ṇ", + ["Ṉ"] = "ṉ", + ["Ṋ"] = "ṋ", + ["Ṍ"] = "ṍ", + ["Ṏ"] = "ṏ", + ["Ṑ"] = "ṑ", + ["Ṓ"] = "ṓ", + ["Ṕ"] = "ṕ", + ["Ṗ"] = "ṗ", + ["Ṙ"] = "ṙ", + ["Ṛ"] = "ṛ", + ["Ṝ"] = "ṝ", + ["Ṟ"] = "ṟ", + ["Ṡ"] = "ṡ", + ["Ṣ"] = "ṣ", + ["Ṥ"] = "ṥ", + ["Ṧ"] = "ṧ", + ["Ṩ"] = "ṩ", + ["Ṫ"] = "ṫ", + ["Ṭ"] = "ṭ", + ["Ṯ"] = "ṯ", + ["Ṱ"] = "ṱ", + ["Ṳ"] = "ṳ", + ["Ṵ"] = "ṵ", + ["Ṷ"] = "ṷ", + ["Ṹ"] = "ṹ", + ["Ṻ"] = "ṻ", + ["Ṽ"] = "ṽ", + ["Ṿ"] = "ṿ", + ["Ẁ"] = "ẁ", + ["Ẃ"] = "ẃ", + ["Ẅ"] = "ẅ", + ["Ẇ"] = "ẇ", + ["Ẉ"] = "ẉ", + ["Ẋ"] = "ẋ", + ["Ẍ"] = "ẍ", + ["Ẏ"] = "ẏ", + ["Ẑ"] = "ẑ", + ["Ẓ"] = "ẓ", + ["Ẕ"] = "ẕ", + ["Ạ"] = "ạ", + ["Ả"] = "ả", + ["Ấ"] = "ấ", + ["Ầ"] = "ầ", + ["Ẩ"] = "ẩ", + ["Ẫ"] = "ẫ", + ["Ậ"] = "ậ", + ["Ắ"] = "ắ", + ["Ằ"] = "ằ", + ["Ẳ"] = "ẳ", + ["Ẵ"] = "ẵ", + ["Ặ"] = "ặ", + ["Ẹ"] = "ẹ", + ["Ẻ"] = "ẻ", + ["Ẽ"] = "ẽ", + ["Ế"] = "ế", + ["Ề"] = "ề", + ["Ể"] = "ể", + ["Ễ"] = "ễ", + ["Ệ"] = "ệ", + ["Ỉ"] = "ỉ", + ["Ị"] = "ị", + ["Ọ"] = "ọ", + ["Ỏ"] = "ỏ", + ["Ố"] = "ố", + ["Ồ"] = "ồ", + ["Ổ"] = "ổ", + ["Ỗ"] = "ỗ", + ["Ộ"] = "ộ", + ["Ớ"] = "ớ", + ["Ờ"] = "ờ", + ["Ở"] = "ở", + ["Ỡ"] = "ỡ", + ["Ợ"] = "ợ", + ["Ụ"] = "ụ", + ["Ủ"] = "ủ", + ["Ứ"] = "ứ", + ["Ừ"] = "ừ", + ["Ử"] = "ử", + ["Ữ"] = "ữ", + ["Ự"] = "ự", + ["Ỳ"] = "ỳ", + ["Ỵ"] = "ỵ", + ["Ỷ"] = "ỷ", + ["Ỹ"] = "ỹ", + ["Ἀ"] = "ἀ", + ["Ἁ"] = "ἁ", + ["Ἂ"] = "ἂ", + ["Ἃ"] = "ἃ", + ["Ἄ"] = "ἄ", + ["Ἅ"] = "ἅ", + ["Ἆ"] = "ἆ", + ["Ἇ"] = "ἇ", + ["Ἐ"] = "ἐ", + ["Ἑ"] = "ἑ", + ["Ἒ"] = "ἒ", + ["Ἓ"] = "ἓ", + ["Ἔ"] = "ἔ", + ["Ἕ"] = "ἕ", + ["Ἠ"] = "ἠ", + ["Ἡ"] = "ἡ", + ["Ἢ"] = "ἢ", + ["Ἣ"] = "ἣ", + ["Ἤ"] = "ἤ", + ["Ἥ"] = "ἥ", + ["Ἦ"] = "ἦ", + ["Ἧ"] = "ἧ", + ["Ἰ"] = "ἰ", + ["Ἱ"] = "ἱ", + ["Ἲ"] = "ἲ", + ["Ἳ"] = "ἳ", + ["Ἴ"] = "ἴ", + ["Ἵ"] = "ἵ", + ["Ἶ"] = "ἶ", + ["Ἷ"] = "ἷ", + ["Ὀ"] = "ὀ", + ["Ὁ"] = "ὁ", + ["Ὂ"] = "ὂ", + ["Ὃ"] = "ὃ", + ["Ὄ"] = "ὄ", + ["Ὅ"] = "ὅ", + ["Ὑ"] = "ὑ", + ["Ὓ"] = "ὓ", + ["Ὕ"] = "ὕ", + ["Ὗ"] = "ὗ", + ["Ὠ"] = "ὠ", + ["Ὡ"] = "ὡ", + ["Ὢ"] = "ὢ", + ["Ὣ"] = "ὣ", + ["Ὤ"] = "ὤ", + ["Ὥ"] = "ὥ", + ["Ὦ"] = "ὦ", + ["Ὧ"] = "ὧ", + ["ᾈ"] = "ᾀ", + ["ᾉ"] = "ᾁ", + ["ᾊ"] = "ᾂ", + ["ᾋ"] = "ᾃ", + ["ᾌ"] = "ᾄ", + ["ᾍ"] = "ᾅ", + ["ᾎ"] = "ᾆ", + ["ᾏ"] = "ᾇ", + ["ᾘ"] = "ᾐ", + ["ᾙ"] = "ᾑ", + ["ᾚ"] = "ᾒ", + ["ᾛ"] = "ᾓ", + ["ᾜ"] = "ᾔ", + ["ᾝ"] = "ᾕ", + ["ᾞ"] = "ᾖ", + ["ᾟ"] = "ᾗ", + ["ᾨ"] = "ᾠ", + ["ᾩ"] = "ᾡ", + ["ᾪ"] = "ᾢ", + ["ᾫ"] = "ᾣ", + ["ᾬ"] = "ᾤ", + ["ᾭ"] = "ᾥ", + ["ᾮ"] = "ᾦ", + ["ᾯ"] = "ᾧ", + ["Ᾰ"] = "ᾰ", + ["Ᾱ"] = "ᾱ", + ["Ὰ"] = "ὰ", + ["Ά"] = "ά", + ["ᾼ"] = "ᾳ", + ["Ὲ"] = "ὲ", + ["Έ"] = "έ", + ["Ὴ"] = "ὴ", + ["Ή"] = "ή", + ["ῌ"] = "ῃ", + ["Ῐ"] = "ῐ", + ["Ῑ"] = "ῑ", + ["Ὶ"] = "ὶ", + ["Ί"] = "ί", + ["Ῠ"] = "ῠ", + ["Ῡ"] = "ῡ", + ["Ὺ"] = "ὺ", + ["Ύ"] = "ύ", + ["Ῥ"] = "ῥ", + ["Ὸ"] = "ὸ", + ["Ό"] = "ό", + ["Ὼ"] = "ὼ", + ["Ώ"] = "ώ", + ["ῼ"] = "ῳ", + ["Ω"] = "ω", + ["K"] = "k", + ["Å"] = "å", + ["Ⅎ"] = "ⅎ", + ["Ⅰ"] = "ⅰ", + ["Ⅱ"] = "ⅱ", + ["Ⅲ"] = "ⅲ", + ["Ⅳ"] = "ⅳ", + ["Ⅴ"] = "ⅴ", + ["Ⅵ"] = "ⅵ", + ["Ⅶ"] = "ⅶ", + ["Ⅷ"] = "ⅷ", + ["Ⅸ"] = "ⅸ", + ["Ⅹ"] = "ⅹ", + ["Ⅺ"] = "ⅺ", + ["Ⅻ"] = "ⅻ", + ["Ⅼ"] = "ⅼ", + ["Ⅽ"] = "ⅽ", + ["Ⅾ"] = "ⅾ", + ["Ⅿ"] = "ⅿ", + ["Ↄ"] = "ↄ", + ["Ⓐ"] = "ⓐ", + ["Ⓑ"] = "ⓑ", + ["Ⓒ"] = "ⓒ", + ["Ⓓ"] = "ⓓ", + ["Ⓔ"] = "ⓔ", + ["Ⓕ"] = "ⓕ", + ["Ⓖ"] = "ⓖ", + ["Ⓗ"] = "ⓗ", + ["Ⓘ"] = "ⓘ", + ["Ⓙ"] = "ⓙ", + ["Ⓚ"] = "ⓚ", + ["Ⓛ"] = "ⓛ", + ["Ⓜ"] = "ⓜ", + ["Ⓝ"] = "ⓝ", + ["Ⓞ"] = "ⓞ", + ["Ⓟ"] = "ⓟ", + ["Ⓠ"] = "ⓠ", + ["Ⓡ"] = "ⓡ", + ["Ⓢ"] = "ⓢ", + ["Ⓣ"] = "ⓣ", + ["Ⓤ"] = "ⓤ", + ["Ⓥ"] = "ⓥ", + ["Ⓦ"] = "ⓦ", + ["Ⓧ"] = "ⓧ", + ["Ⓨ"] = "ⓨ", + ["Ⓩ"] = "ⓩ", + ["Ⰰ"] = "ⰰ", + ["Ⰱ"] = "ⰱ", + ["Ⰲ"] = "ⰲ", + ["Ⰳ"] = "ⰳ", + ["Ⰴ"] = "ⰴ", + ["Ⰵ"] = "ⰵ", + ["Ⰶ"] = "ⰶ", + ["Ⰷ"] = "ⰷ", + ["Ⰸ"] = "ⰸ", + ["Ⰹ"] = "ⰹ", + ["Ⰺ"] = "ⰺ", + ["Ⰻ"] = "ⰻ", + ["Ⰼ"] = "ⰼ", + ["Ⰽ"] = "ⰽ", + ["Ⰾ"] = "ⰾ", + ["Ⰿ"] = "ⰿ", + ["Ⱀ"] = "ⱀ", + ["Ⱁ"] = "ⱁ", + ["Ⱂ"] = "ⱂ", + ["Ⱃ"] = "ⱃ", + ["Ⱄ"] = "ⱄ", + ["Ⱅ"] = "ⱅ", + ["Ⱆ"] = "ⱆ", + ["Ⱇ"] = "ⱇ", + ["Ⱈ"] = "ⱈ", + ["Ⱉ"] = "ⱉ", + ["Ⱊ"] = "ⱊ", + ["Ⱋ"] = "ⱋ", + ["Ⱌ"] = "ⱌ", + ["Ⱍ"] = "ⱍ", + ["Ⱎ"] = "ⱎ", + ["Ⱏ"] = "ⱏ", + ["Ⱐ"] = "ⱐ", + ["Ⱑ"] = "ⱑ", + ["Ⱒ"] = "ⱒ", + ["Ⱓ"] = "ⱓ", + ["Ⱔ"] = "ⱔ", + ["Ⱕ"] = "ⱕ", + ["Ⱖ"] = "ⱖ", + ["Ⱗ"] = "ⱗ", + ["Ⱘ"] = "ⱘ", + ["Ⱙ"] = "ⱙ", + ["Ⱚ"] = "ⱚ", + ["Ⱛ"] = "ⱛ", + ["Ⱜ"] = "ⱜ", + ["Ⱝ"] = "ⱝ", + ["Ⱞ"] = "ⱞ", + ["Ⱡ"] = "ⱡ", + ["Ɫ"] = "ɫ", + ["Ᵽ"] = "ᵽ", + ["Ɽ"] = "ɽ", + ["Ⱨ"] = "ⱨ", + ["Ⱪ"] = "ⱪ", + ["Ⱬ"] = "ⱬ", + ["Ⱶ"] = "ⱶ", + ["Ⲁ"] = "ⲁ", + ["Ⲃ"] = "ⲃ", + ["Ⲅ"] = "ⲅ", + ["Ⲇ"] = "ⲇ", + ["Ⲉ"] = "ⲉ", + ["Ⲋ"] = "ⲋ", + ["Ⲍ"] = "ⲍ", + ["Ⲏ"] = "ⲏ", + ["Ⲑ"] = "ⲑ", + ["Ⲓ"] = "ⲓ", + ["Ⲕ"] = "ⲕ", + ["Ⲗ"] = "ⲗ", + ["Ⲙ"] = "ⲙ", + ["Ⲛ"] = "ⲛ", + ["Ⲝ"] = "ⲝ", + ["Ⲟ"] = "ⲟ", + ["Ⲡ"] = "ⲡ", + ["Ⲣ"] = "ⲣ", + ["Ⲥ"] = "ⲥ", + ["Ⲧ"] = "ⲧ", + ["Ⲩ"] = "ⲩ", + ["Ⲫ"] = "ⲫ", + ["Ⲭ"] = "ⲭ", + ["Ⲯ"] = "ⲯ", + ["Ⲱ"] = "ⲱ", + ["Ⲳ"] = "ⲳ", + ["Ⲵ"] = "ⲵ", + ["Ⲷ"] = "ⲷ", + ["Ⲹ"] = "ⲹ", + ["Ⲻ"] = "ⲻ", + ["Ⲽ"] = "ⲽ", + ["Ⲿ"] = "ⲿ", + ["Ⳁ"] = "ⳁ", + ["Ⳃ"] = "ⳃ", + ["Ⳅ"] = "ⳅ", + ["Ⳇ"] = "ⳇ", + ["Ⳉ"] = "ⳉ", + ["Ⳋ"] = "ⳋ", + ["Ⳍ"] = "ⳍ", + ["Ⳏ"] = "ⳏ", + ["Ⳑ"] = "ⳑ", + ["Ⳓ"] = "ⳓ", + ["Ⳕ"] = "ⳕ", + ["Ⳗ"] = "ⳗ", + ["Ⳙ"] = "ⳙ", + ["Ⳛ"] = "ⳛ", + ["Ⳝ"] = "ⳝ", + ["Ⳟ"] = "ⳟ", + ["Ⳡ"] = "ⳡ", + ["Ⳣ"] = "ⳣ", + ["A"] = "a", + ["B"] = "b", + ["C"] = "c", + ["D"] = "d", + ["E"] = "e", + ["F"] = "f", + ["G"] = "g", + ["H"] = "h", + ["I"] = "i", + ["J"] = "j", + ["K"] = "k", + ["L"] = "l", + ["M"] = "m", + ["N"] = "n", + ["O"] = "o", + ["P"] = "p", + ["Q"] = "q", + ["R"] = "r", + ["S"] = "s", + ["T"] = "t", + ["U"] = "u", + ["V"] = "v", + ["W"] = "w", + ["X"] = "x", + ["Y"] = "y", + ["Z"] = "z", + ["𐐀"] = "𐐨", + ["𐐁"] = "𐐩", + ["𐐂"] = "𐐪", + ["𐐃"] = "𐐫", + ["𐐄"] = "𐐬", + ["𐐅"] = "𐐭", + ["𐐆"] = "𐐮", + ["𐐇"] = "𐐯", + ["𐐈"] = "𐐰", + ["𐐉"] = "𐐱", + ["𐐊"] = "𐐲", + ["𐐋"] = "𐐳", + ["𐐌"] = "𐐴", + ["𐐍"] = "𐐵", + ["𐐎"] = "𐐶", + ["𐐏"] = "𐐷", + ["𐐐"] = "𐐸", + ["𐐑"] = "𐐹", + ["𐐒"] = "𐐺", + ["𐐓"] = "𐐻", + ["𐐔"] = "𐐼", + ["𐐕"] = "𐐽", + ["𐐖"] = "𐐾", + ["𐐗"] = "𐐿", + ["𐐘"] = "𐑀", + ["𐐙"] = "𐑁", + ["𐐚"] = "𐑂", + ["𐐛"] = "𐑃", + ["𐐜"] = "𐑄", + ["𐐝"] = "𐑅", + ["𐐞"] = "𐑆", + ["𐐟"] = "𐑇", + ["𐐠"] = "𐑈", + ["𐐡"] = "𐑉", + ["𐐢"] = "𐑊", + ["𐐣"] = "𐑋", + ["𐐤"] = "𐑌", + ["𐐥"] = "𐑍", + ["𐐦"] = "𐑎", + ["𐐧"] = "𐑏", +} + + +return { + utf8_lc_uc = utf8_lc_uc, + utf8_uc_lc = utf8_uc_lc, +} diff --git a/mac/.config/mpv/script-opts/SimpleBookmark.conf b/mac/.config/mpv/script-opts/SimpleBookmark.conf new file mode 100644 index 0000000..85192f1 --- /dev/null +++ b/mac/.config/mpv/script-opts/SimpleBookmark.conf @@ -0,0 +1,311 @@ +######----Settings For SimpleBookmark 1.3.1----###### +####------Script Settings-----#### +##--Filters: (all/keybinds/groups/recents/distinct/protocols/fileonly/titleonly/timeonly/keywords). +##--Filters description: 'all' to display all the items. Or 'groups' to display the list filtered with items added to any group. Or 'keybinds' to display the list filtered with keybind slots. Or "recents" to display recently added items to log without duplicate. Or "distinct" to show recent saved entries for files in different paths. Or "fileonly" to display files saved without time. Or "timeonly" to display files that have time only. Or "keywords" to display files with matching keywords specified in the configuration. Or "playing" to show list of current playing file. +##--Filters can also be stacked by using %+% or omitted by using %-%. e.g.: "groups%+%keybinds" shows only groups and keybinds, "all%-%groups%-%keybinds" shows all items without groups and without keybinds. +##--Also defined groups can be called by using /:group%Group Name% + +#--(none/Filters). Auto run the list when opening mpv and there is no video / file loaded. none for disabled. Or choose between filters. +auto_run_list_idle=none + +#--(0/#number). Runs a saved entry when mpv starts based on its number. -1 for oldest entry. 1 for latest entry. number, e.g.: 13 to load a specific entry. 0 for disabled +load_item_on_startup=0 + +#--(yes/no). Hides OSC idle screen message when opening and closing menu (could cause unexpected behavior if multiple scripts are triggering osc-idlescreen off) +toggle_idlescreen=yes + +#--(#number). Change to 0 so item resumes from the exact position, or decrease the value so that it gives you a little preview before loading the resume point +resume_offset=-0.65 + +#--(yes/no). Display osd messages when actions occur. +osd_messages=yes + +#--(yes/no). When attempting to bookmark, if there is no video / file loaded, it will instead jump to your last bookmarked item +bookmark_loads_last_idle=yes + +#--(yes/no). When attempting to bookmark fileonly, if there is no video / file loaded, it will instead jump to your last bookmarked item without resuming. +bookmark_fileonly_loads_last_idle=yes + +#--(yes/no). Marks the bookmarked time as a chapter +mark_bookmark_as_chapter=no + +#--(yes/no). Preserve video settings when bookmarking items and loading bookmarks by writing mpv watch-later config +preserve_video_settings=no + +#--["keybind","..."]. Keybind that will be used to save the video and its time to log file +bookmark_save_keybind=["ctrl+'", "ctrl+`"] + +#--["keybind","..."]. Keybind that will be used to save the video without time to log file +bookmark_fileonly_keybind=["", ""] + +#--[ ["keybind","Filters"], ["...",""..."] ]. Keybind that will be used to open the list along with the specified filter. +open_list_keybind=[ ["'", "all"], ["`", "all"], ["", "keybinds"], ["", "keybinds"] ] + +#--[ ["keybind","Filters"], ["...",""..."] ]. Keybind that is used while the list is open to jump to the specific filter (it also enables pressing a filter keybind twice to close list). Available filters: "all", "keybinds", "recents", "distinct", "protocols", "fileonly", "titleonly", "timeonly", "keywords". +list_filter_jump_keybind=[ ["b", "all"], ["B", "all"], ["k", "keybinds"], ["K", "keybinds"], ["!", "/:group%TV Shows%"], ["@", "/:group%Movies%"], ["SHARP", "/:group%Anime%"], ["$", "/:group%Anime Movies%"], ["%", "/:group%Cartoon%"], ["r", "recents"], ["R", "recents"], ["d", "distinct"], ["D", "distinct"], ["f", "fileonly"], ["F", "fileonly"] ] + +####------Keybind Slots Settings-------#### + +#--(yes/no). Quick saving a bookmark to keybind slot will not save position +keybinds_quicksave_fileonly=yes + +#--(yes/no). If the keybind slot is empty, this enables quick bookmarking and adding to slot, Otherwise keybinds are assigned from the bookmark list or via quicksave. +keybinds_empty_auto_create=no + +#--(yes/no). When auto creating keybind slot, it will not save position. This config requires "keybinds_empty_auto_create=yes". +keybinds_empty_fileonly=yes + +#--(yes/no). Loading a keybind slot resumes to the bookmarked time. +keybinds_auto_resume=yes + +#--["keybind","..."]. Keybind that will be used to bind list item to a key, as well as to load it. e.g.: Press alt+1 on list cursor position to add it, press alt+1 while list is hidden to load item keybinded into alt+1. (A new slot is automatically created for each keybind. e.g: .."alt+9, alt+0". Where alt+0 creates a new 10th slot.) +keybinds_add_load_keybind=["", "", "", "", "", "", "", "", ""] + +#--["keybind","..."]. To save keybind to a slot without opening the list, to load these keybinds it uses keybinds_add_load_keybind +keybinds_quicksave_keybind=["", "", "", "", "", "", "", "", ""] + +#--["keybind","..."]. Keybind that is used when list is open to remove the keybind slot based on cursor position +keybinds_remove_keybind=["alt+-"] + +#--["keybind","..."]. Keybind that is used when list is open to remove the keybind slot based on highlighted items +keybinds_remove_highlighted_keybind=["alt+_"] + +####------Group Settings-------#### + +#--["keybind","..."]. Define the groups that can be assigned to a bookmarked item, you can also optionally assign the keybind that puts the bookmarked item into the relevant group when the list is open. Alternatively you can use list_group_add_cycle_keybind to assign item to a group +groups_list_and_keybind=[ ["TV Shows", "ctrl+1", "ctrl+!"], ["Movies", "ctrl+2", "ctrl+@"], ["Anime", "ctrl+3", "ctrl+#"], ["Anime Movies", "ctrl+4", "ctrl+$"], ["Cartoon", "ctrl+5"], ["Animated Movies"] ] + +#--["keybind","..."]. Keybind that is used when list is open to remove the group based on cursor position +list_groups_remove_keybind=["ctrl+-"] + +#--["keybind","..."]. Keybind that is used when list is open to remove the group based on highlighted items +list_groups_remove_highlighted_keybind=["ctrl+_"] + +#--["keybind","..."]. Keybind to add an item to the group, this cycles through all the different available groups when list is open +list_group_add_cycle_keybind=["ctrl+g"] + +#--["keybind","..."]. Keybind to add highlighted items to the group, this cycles through all the different available groups when list is open +list_group_add_cycle_highlighted_keybind=["ctrl+G"] + +####------Logging Settings------#### + +#--(path). Change to "/:dir%script%" for placing it in the same directory of script, OR change to "/:dir%mpvconf%" for mpv portable_config directory. OR write any variable using "/:var" then the variable "/:var%APPDATA%" you can use path also, such as: "/:var%APPDATA%\mpv" OR "/:var%HOME%/mpv" OR specify the absolute path , e.g.: 'C:\Users\Eisa01\Desktop\' +log_path=/:dir%mpvconf% + +#--(name.extension). of the file that will be used to store the log data +log_file=mpvBookmark.log + +#--(all/protocols/local/none). Store media title in log file, useful for websites / protocols because title cannot be parsed from links alone +file_title_logging=all + +#--["protocol:","..."]. Add below (after a comma) any protocol you want its title to be stored in the log file. This is valid only for (file_title_logging = "protocols" or file_title_logging = "all") +logging_protocols=["https?://", "magnet:", "rtmp:"] + +#--(#number). Limit saving entries with same path: -1 for unlimited, 0 will always update entries of same path, e.g. value of 3 will have the limit of 3 then it will start updating old values on the 4th entry. +same_entry_limit=-1 + +#--(yes/no). to preserve groups / slots or any other property when an entry is overwritten. +overwrite_preserve_properties=yes + + +####------List Settings-------#### + +#--(yes/no). Going up on the first item loops towards the last item and vise-versa. +loop_through_list=no + +#--(yes/no). Display new entries after reaching the middle of list. +list_middle_loader=yes + +#--(yes/no). Keybind entries from 0 to 9 for quick selection when list is open (list_show_amount = 10 is maximum for this feature to work) +quickselect_0to9_keybind=no + +#--(yes/no). Exit the list when double tapping the main list, even if the list was accessed through a different filter. +main_list_keybind_twice_exits=yes + +#--(yes/no). To smartly set the search as not typing (when search box is open) without needing to press ctrl+enter. +search_not_typing_smartly=yes + +#--(any/any-notime). 'any' to find any typed search based on combination of date, title, path / url, and time. 'any-notime' to find any typed search based on combination of date, title, and path / url, but without looking for time (this is to reduce unwanted results). +search_behavior=any + +####------Filter Settings-------#### + +#--["Filters","..."]. Jump to the following filters and in the shown sequence when navigating via left and right keys. You can change the sequence and delete filters that are not needed. +filters_and_sequence=["all", "keybinds", "groups", "/:group%TV Shows%", "/:group%Movies%", "/:group%Anime%", "/:group%Anime Movies%", "/:group%Cartoon%", "/:group%Animated Movies%", "protocols", "fileonly", "titleonly", "timeonly", "playing", "keywords", "recents", "distinct", "keybinds%+%groups", "all%-%groups%-%keybinds"] + +#--["keybind","..."]. Keybind that will be used to go to the next available filter based on the filters_and_sequence +next_filter_sequence_keybind=["RIGHT", "MBTN_FORWARD"] + +#--["keybind","..."]. Keybind that will be used to go to the previous available filter based on the filters_and_sequence +previous_filter_sequence_keybind=["LEFT", "MBTN_BACK"] + +#--(yes/no). Bypass the last filter to go to first filter when navigating through filters using arrow keys, and vice-versa. +loop_through_filters=yes + +#--["string","..."]. Create a filter out of your desired 'keywords', e.g.: youtube.com will filter out the videos from youtube. You can also insert a portion of filename or title, or extension or a full path / portion of a path. e.g.: ["youtube.com", "mp4", "naruto", "c:\\users\\eisa01\\desktop"]. To disable this filter keep it empty [] +keywords_filter_list=[] + +####------Sort Settings-------#### +##--Sorts: added-asc, added-desc, time-asc, time-desc, alphanum-asc, alphanum-desc +##--Sorts description: 'added-asc' is for the newest added item to show first. Or 'added-desc' for the newest added to show last. Or 'alphanum-asc' is for A to Z approach with filename and episode number lower first. Or 'alphanum-desc' is for its Z to A approach. Or 'time-asc', 'time-desc' to sort the list based on time. + +#--(Sorts). Default sorting method for all the different filters in the list. Choose between available sorts. +list_default_sort=added-asc + +#--[ ["Filters","Sorts"], ["...",""..."] ]. Default sort for specific filters, e.g.: [ ["all", "alphanum-asc"], ["playing", "added-desc"] ] +list_filters_sort=[ ["keybinds", "keybind-asc"], ["fileonly", "alphanum-asc"], ["playing", "time-asc"] ] + +#--["keybind","..."]. Keybind to cycle through the different available sorts when list is open +list_cycle_sort_keybind=["alt+s", "alt+S"] + +####------List Design Settings------#### + +#--(0-9) The alignment for the list, uses numpad positions choose from 1-9 or 0 to disable. e,g.:7 top left alignment, 8 top middle alignment, 9 top right alignment. +list_alignment=7 + +#--(yes/no). Slices long names per the amount specified below +slice_name=no + +#--(#number). Amount for slicing long names (for path, name, and title) list_content_text variables +slice_name_amount=55 + +#--(#number). Change maximum number to show items at once +list_show_amount=10 + +#--The formatting of the items when you open the list +#--list_content_text variables: %quickselect%, %number%, %name%, %title%, %path%, %duration%, %length%, %remaining%, %dt%, %dt_"format%"% +#--Variables explanation: %quickselect%: keybind for quickselect. %number%: numbered sequence of the item position. %name%: shows the file name. %title%: shows file title. %path%: shows the filepath or url. %duration%: the reached playback time of item. %length%: the total time length of the file. %remaining% the remaining playback time of file. %dt%: the logged date and time. +#--You can also use %dt_"format%"%" as per lua date formatting (https://www.lua.org/pil/22.1.html). It is specified after dt_ ..example: (%dt_%a% %dt_%b% %dt_%y%) for abbreviated day month year +list_content_text=%number%. %name%%0_duration%%duration%%0_keybind%%keybind%%0_group%%group%%1_group%\h\N\N + +#--User defined variables that only displays if the related variable is triggered. +#--#_group, #_keybind, #_duration, #_length, #_remaining, #_dt. (# represents the possibility of creating many variables using different numbers. e.g.: "0_keybind", "1_keybind") +list_content_variables=[ ["0_duration", " 🕒 "], ["0_keybind", " ⌨ "], ["0_group", " 🖿 "] ] + +#--(string). The text that indicates there are more items above. \N is for new line. \h is for hard space. +list_sliced_prefix=...\h\N\N + +#--(string). The text that indicates there are more items below +list_sliced_suffix=... + +#--(BGR hexadcimal code). Text color for list +text_color=ffffff + +#--(#number). Font size for the text of list +text_scale=50 + +#--(#number). Black border size for the text of list +text_border=0.7 + +#--(BGR hexadcimal code). Text color of current cursor position +text_cursor_color=ffbf7f + +#--(#number). Font size for text of current cursor position in list +text_cursor_scale=50 + +#--(#number). Black border size for text of current cursor position in list +text_cursor_border=0.7 + +#--(string). Pre text for highlighted multi-select item +text_highlight_pre_text=✅ + +#--(BGR hexadcimal code). Search color when in typing mode +search_color_typing=00bfff + +#--(BGR hexadcimal code). Search color when not in typing mode and it is active +search_color_not_typing=ffffaa + +#--(BGR hexadcimal code). Header color +header_color=ffffaa + +#--(#number). Header text size for the list +header_scale=55 + +#--(#number). Black border size for the Header of list +header_border=0.8 + +#--Text to be shown as header for the list +#--header_text variables: %cursor%, %total%, %highlight%, %filter%, %search%, %duration%, %length%, %remaining%. +#--Variables explanation: %cursor%: the number of cursor position. %total%: total amount in current list. %highlight%: total number of highlighted items. %filter%: shows the filter name, %search%: shows the typed search. %duration%: the total reached playback time of all displayed items. %length%: the total time length of the file for all displayed items. %remaining% the remaining playback time of file for all the displayed items. +header_text=🔖 Bookmarks [%cursor%/%total%]%0_highlight%%highlight%%0_filter%%filter%%1_filter%%0_sort%%sort%%1_sort%%0_search%%search%%1_search%\h\N\N + +#--User defined variables that only displays if the related variable is triggered. +#--#_filter, #_sort, #_highlight, #_search, #_duration, #_length%, #_remaining. (# represents the possibility of creating many variables using different numbers. e.g.: "0_filter", "1_filter") +header_variables=[ ["0_highlight", "✅"], ["0_filter", " [Filter: "], ["1_filter", "]"], ["0_sort", " \\{"], ["1_sort", "}"], ["0_search", "\\h\\N\\N[Search="], ["1_search", "..]"] ] + +#--(sorts). Sort method that is hidden from header when using %sort% variable +header_sort_hide_text=added-asc + +####-----Time Format Settings-----#### +##--in the first parameter, you can define from the available styles: default, hms, hms-full, timestamp, timestamp-concise "default" to show in HH:MM:SS.sss format. "hms" to show in 1h 2m 3.4s format. "hms-full" is the same as hms but keeps the hours and minutes persistent when they are 0. "timestamp" to show the total time as timestamp 123456.700 format. "timestamp-concise" shows the total time in 123456.7 format (shows and hides decimals depending on availability). +##--in the second parameter, you can define whether to show milliseconds, round them or truncate them. Available options: 'truncate' to remove the milliseconds and keep the seconds. 0 to remove the milliseconds and round the seconds. 1 or above is the amount of milliseconds to display. The default value is 3 milliseconds. +##--in the third parameter you can define the seperator between hour:minute:second. "default" style is automatically set to ":", "hms", "hms-full" are automatically set to " ". +##--Some examples: ["default", 3, "-"],["hms-full", 5, "."],["hms", "truncate", ":"],["timestamp-concise"],["timestamp", 0],["timestamp", "truncate"],["timestamp", 5] + +osd_time_format=["default", "truncate"] +list_duration_time_format=["default", "truncate"] +list_length_time_format=["default", "truncate"] +list_remaining_time_format=["default", "truncate"] +header_duration_time_format=["hms", "truncate", ":"] +header_length_time_format=["hms", "truncate", ":"] +header_remaining_time_format=["hms", "truncate", ":"] + +####------List Keybind Settings------#### +#--Add below (after a comma) any additional keybind you want to bind. Or change the letter inside the quotes to change the keybind +#--Example: ["alt+b"] / ["b", "B"] / ["a" "ctrl+a", "alt+a"] + +#--Keybind that will be used to navigate up on the list +list_move_up_keybind=["UP", "WHEEL_UP"] + +#--Keybind that will be used to navigate down on the list +list_move_down_keybind=["DOWN", "WHEEL_DOWN"] + +#--Keybind that will be used to go to the first item for the page shown on the list +list_page_up_keybind=["PGUP"] + +#--Keybind that will be used to go to the last item for the page shown on the list +list_page_down_keybind=["PGDWN"] + +#--Keybind that will be used to navigate to the first item on the list +list_move_first_keybind=["HOME"] + +#--Keybind that will be used to navigate to the last item on the list +list_move_last_keybind=["END"] + +#--Keybind that will be used to highlight while pressing a navigational keybind, keep holding shift and then press any navigation keybind, such as: up, down, home, pgdwn, etc.. +list_highlight_move_keybind=["SHIFT"] + +#--Keybind that will be used to highlight all displayed items on the list +list_highlight_all_keybind=["ctrl+a", "ctrl+A"] + +#--Keybind that will be used to remove all currently highlighted items from the list +list_unhighlight_all_keybind=["ctrl+d", "ctrl+D"] + +#--Keybind that will be used to load entry based on cursor position +list_select_keybind=["ENTER", "MBTN_MID"] + +#--Keybind that will be used to add entry to playlist based on cursor position +list_add_playlist_keybind=["CTRL+ENTER"] + +#--Keybind that will be used to add all highlighted entries to playlist +list_add_playlist_highlighted_keybind=["SHIFT+ENTER"] + +#--Keybind that will be used to close the list (closes search first if it is open) +list_close_keybind=["ESC", "MBTN_RIGHT"] + +#--Keybind that will be used to delete the entry based on cursor position +list_delete_keybind=["DEL"] + +#--Keybind that will be used to delete all highlighted entries from the list +list_delete_highlighted_keybind=["SHIFT+DEL"] + +#--Keybind that will be used to trigger search +list_search_activate_keybind=["ctrl+f", "ctrl+F"] + +#--Keybind that will be used to exit typing mode of search while keeping search open +list_search_not_typing_mode_keybind=["ALT+ENTER"] + +#--Keybind thats are ignored when list is open +list_ignored_keybind=["h", "H", "r", "R", "c", "C"] + +######----End of Settings----###### diff --git a/mac/.config/mpv/script-opts/SmartCopyPaste_II.conf b/mac/.config/mpv/script-opts/SmartCopyPaste_II.conf new file mode 100644 index 0000000..d0fd99a --- /dev/null +++ b/mac/.config/mpv/script-opts/SmartCopyPaste_II.conf @@ -0,0 +1,343 @@ +######----Settings For SmartCopyPaste_II 3.1----###### +####------Script Settings-----#### + +#--auto is for automatic device detection, or manually change to: windows or mac or linux +device=auto + +#--copy command that will be used in Linux. OR write a different command +linux_copy=xclip -silent -selection clipboard -in + +#--paste command that will be used in Linux. OR write a different command +linux_paste=xclip -selection clipboard -o + +#--copy command that will be used in MAC. OR write a different command +mac_copy=pbcopy + +#--paste command that will be used in MAC. OR write a different command +mac_paste=pbpaste + +#--powershell is for using windows powershell to copy. OR write the copy command, e.g:clip +windows_copy=powershell + +#--powershell is for using windows powershell to paste. OR write the paste command +windows_paste=powershell + +#--Auto run the list when opening mpv and there is no video / file loaded. 'none' for disabled. Or choose between: all, copy, paste, recents, distinct, protocols, fileonly, titleonly, timeonly, keywords. +auto_run_list_idle=none + +#--hides OSC idle screen message when opening and closing menu (could cause unexpected behavior if multiple scripts are triggering osc-idlescreen off) +toggle_idlescreen=no + +#--change to 0 so item resumes from the exact position, or decrease the value so that it gives you a little preview before loading the resume point +resume_offset=-0.65 + +#--yes is for displaying osd messages when actions occur. Change to no will disable all osd messages generated from this script +osd_messages=yes + +#--yes is for marking the time as a chapter. no disables mark as chapter behavior. +mark_clipboard_as_chapter=no + +#--Option to copy time with video. Select between: local, protocols, specifics, all, none. 'none' for disabled, 'all' to copy time for all videos, 'protocols' for copying time only for protocols, 'specifics' to copy time only for websites defined below, 'local' to copy time for videos that are not protocols +copy_time_method=all + +#--Behavior of paste when nothing valid is copied, and no video is running. select between: force, force-noresume +log_paste_idle_behavior=force-noresume + +#--Behavior of paste when nothing valid is copied, and a video is running. select between: timestamp>playlist, timestamp>force, timestamp, playlist, force, force-noresume +log_paste_running_behavior=timestamp>playlist + +#--The time attributes which will be added when copying protocols of specific websites from this list. Additional attributes can be added following the same format. +specific_time_attributes=[ ["twitter", "?t=", ""], ["twitch", "?t=", "s"], ["youtube", "&t=", "s"] ] + +#--The text that will be copied before the seek time when copying a protocol video from mpv +protocols_time_attribute=&t= + +#--The text that will be copied before the seek time when copying a local video from mpv +local_time_attribute=&time= + +#--The time attributes that can be pasted for resume, specific_time_attributes, protocols_time_attribute, local_time_attribute are automatically added +pastable_time_attributes=[" | time="] + +#--Keybind that will be used to copy +copy_keybind=["ctrl+y", "ctrl+Y", "meta+y", "meta+Y"] + +#--The priority of paste behavior when a video is running. Select between: playlist, timestamp, force. +running_paste_behavior=playlist + +#--Keybind that will be used to paste +paste_keybind=["ctrl+p", "ctrl+P", "meta+p", "meta+P"] + +#--Copy behavior when using copy_specific_keybind. Select between: title, path, timestamp, path×tamp. +copy_specific_behavior=path + +#--Keybind that will be used to copy based on the copy behavior specified +copy_specific_keybind=["ctrl+alt+y", "ctrl+alt+Y", "meta+alt+y", "meta+alt+Y"] + +#--Paste behavior when using paste_specific_keybind. Select between: playlist, timestamp, force. +paste_specific_behavior=playlist + +#--Keybind that will be used to paste based on the paste behavior specified +paste_specific_keybind=["ctrl+alt+y", "ctrl+alt+Y", "meta+alt+y", "meta+alt+Y"] + +#--add below (after a comma) any protocol you want paste to work with; e.g: ,'ftp://'. Or set it as "" by deleting all defined protocols to make paste works with any protocol. +paste_protocols=["https?://", "magnet:", "rtmp:", "file:"] + +#--add below (after a comma) any extension you want paste to work with; e.g: ,'pdf'. Or set it as "" by deleting all defined extension to make paste works with any extension. +paste_extensions=["ac3", "a52", "eac3", "mlp", "dts", "dts-hd", "dtshd", "yes-hd", "thd", "yeshd", "thd+ac3", "tta", "pcm", "wav", "aiff", "aif", "aifc", "amr", "awb", "au", "snd", "lpcm", "yuv", "y4m", "ape", "wv", "shn", "m2ts", "m2t", "mts", "mtv", "ts", "tsv", "tsa", "tts", "trp", "adts", "adt", "mpa", "m1a", "m2a", "mp1", "mp2", "mp3", "mpeg", "mpg", "mpe", "mpeg2", "m1v", "m2v", "mp2v", "mpv", "mpv2", "mod", "tod", "vob", "vro", "evob", "evo", "mpeg4", "m4v", "mp4", "mp4v", "mpg4", "m4a", "aac", "h264", "avc", "x264", "264", "hevc", "h265", "x265", "265", "flac", "oga", "ogg", "opus", "spx", "ogv", "ogm", "ogx", "mkv", "mk3d", "mka", "webm", "weba", "avi", "vfw", "divx", "3iv", "xvid", "nut", "flic", "fli", "flc", "nsv", "gxf", "mxf", "wma", "wm", "wmv", "asf", "dvr-ms", "dvr", "wtv", "dv", "hdv", "flv", "f4v", "f4a", "qt", "mov", "hdmov", "rm", "rmvb", "ra", "ram", "3ga", "3ga2", "3gpp", "3gp", "3gp2", "3g2", "ay", "gbs", "gym", "hes", "kss", "nsf", "nsfe", "sap", "spc", "vgm", "vgz", "m3u", "m3u8", "pls", "cue", "ase", "art", "bmp", "blp", "cd5", "cit", "cpt", "cr2", "cut", "dds", "dib", "djvu", "egt", "exif", "gif", "gpl", "grf", "icns", "ico", "iff", "jng", "jpeg", "jpg", "jfif", "jp2", "jps", "lbm", "max", "miff", "mng", "msp", "nitf", "ota", "pbm", "pc1", "pc2", "pc3", "pcf", "pcx", "pdn", "pgm", "PI1", "PI2", "PI3", "pict", "pct", "pnm", "pns", "ppm", "psb", "psd", "pdd", "psp", "px", "pxm", "pxr", "qfx", "raw", "rle", "sct", "sgi", "rgb", "int", "bw", "tga", "tiff", "tif", "vtf", "xbm", "xcf", "xpm", "3dv", "amf", "ai", "awg", "cgm", "cdr", "cmx", "dxf", "e2d", "egt", "eps", "fs", "gbr", "odg", "svg", "stl", "vrml", "x3d", "sxd", "v2d", "vnd", "wmf", "emf", "art", "xar", "png", "webp", "jxr", "hdp", "wdp", "cur", "ecw", "iff", "lbm", "liff", "nrrd", "pam", "pcx", "pgf", "sgi", "rgb", "rgba", "bw", "int", "inta", "sid", "ras", "sun", "tga", "torrent"] + +#--add below (after a comma) any extension you want paste to attempt to add as a subtitle file, e.g.:'txt'. Or set it as "" by deleting all defined extension to make paste attempt to add any subtitle. +paste_subtitles=["aqt", "gsub", "jss", "sub", "ttxt", "pjs", "psb", "rt", "smi", "slt", "ssf", "srt", "ssa", "ass", "usf", "idx", "vtt"] + +#--Keybind that will be used to open the list along with the specified filter. Available filters: "all", "copy", "paste", "recents", "distinct", "protocols", "fileonly", "titleonly", "timeonly", "keywords". +open_list_keybind=[ ["y", "all"], ["Y", "all"] ] + +#--Keybind that is used while the list is open to jump to the specific filter (it also enables pressing a filter keybind twice to close list). Available filters: "all", "copy", "paste", "recents", "distinct", "protocols", "fileonly", "titleonly", "timeonly", "keywords". +list_filter_jump_keybind=[ ["y", "all"], ["Y", "all"], ["r", "recents"], ["R", "recents"], ["d", "distinct"], ["D", "distinct"], ["f", "fileonly"], ["F", "fileonly"] ] + +####------Logging Settings------#### + +#--Change to "/:dir%script%" for placing it in the same directory of script, OR change to "/:dir%mpvconf%" for mpv portable_config directory. OR write any variable using "/:var" then the variable "/:var%APPDATA%" you can use path also, such as: "/:var%APPDATA%\mpv" OR "/:var%HOME%/mpv" OR specify the absolute path , e.g.: 'C:\Users\Eisa01\Desktop\' +log_path=/:dir%mpvconf% + +#--name+extension of the file that will be used to store the log data +log_file=mpvClipboard.log + +#--Date format in the log (see lua date formatting), e.g.:"%d/%m/%y %X" or "%d/%b/%y %X" +date_format=%A/%B %d/%m/%Y %X + +#--Change between all, protocols, none. This option will store the media title in log file, it is useful for websites / protocols because title cannot be parsed from links alone +file_title_logging=protocols + +#--add below (after a comma) any protocol you want its title to be stored in the log file. This is valid only for (file_title_logging = "protocols" or file_title_logging = "all") +logging_protocols=["https?://", "magnet:", "rtmp:"] + +#--Prefers to copy and log filename over filetitle. Select between: local, protocols, all, none. 'local' prefer filenames for videos that are not protocols. 'protocols' will prefer filenames for protocols only. 'all' will prefer filename over filetitle for both protocols and not protocols videos. 'none' will always use filetitle instead of filename +prefer_filename_over_title=local + +#--Limit saving entries with same path: -1 for unlimited, 0 will always update entries of same path, e.g. value of 3 will have the limit of 3 then it will start updating old values on the 4th entry. +same_entry_limit=4 + +####------List Settings-------#### + +#--yes is for going up on the first item loops towards the last item and vise-versa. no disables this behavior. +loop_through_list=no + +#--no is for more items to show, then u must reach the end. yes is for new items to show after reaching the middle of list. +list_middle_loader=yes + +#--Show file paths instead of media-title +show_paths=no + +#--Show the number of each item before displaying its name and values. +show_item_number=yes + +#--Change to yes or no. Slices long filenames per the amount specified below +slice_longfilenames=no + +#--Amount for slicing long filenames +slice_longfilenames_amount=55 + +#--Change maximum number to show items at once +list_show_amount=10 + +#--Keybind entries from 0 to 9 for quick selection when list is open (list_show_amount = 10 is maximum for this feature to work) +quickselect_0to9_keybind=yes + +#--Will exit the list when double tapping the main list, even if the list was accessed through a different filter. +main_list_keybind_twice_exits=yes + +#--To smartly set the search as not typing (when search box is open) without needing to press ctrl+enter. +search_not_typing_smartly=yes + +#--"specific" to find a match of either a date, title, path / url, time. "any" to find any typed search based on combination of date, title, path / url, and time. "any-notime" to find any typed search based on combination of date, title, and path / url, but without looking for time (this is to reduce unwanted results). +search_behavior=any + +####------Filter Settings-------#### +##--available filters: "all" to display all the items. Or "copy" to display copied items. Or "paste" to display pasted items. Or "recents" to display recently added items to log without duplicate. Or "distinct" to show recent saved entries for files in different paths. Or "fileonly" to display files saved without time. Or "timeonly" to display files that have time only. Or "keywords" to display files with matching keywords specified in the configuration. Or "playing" to show list of current playing file. + +#--Jump to the following filters and in the shown sequence when navigating via left and right keys. You can change the sequence and delete filters that are not needed. +filters_and_sequence=["all", "copy", "paste", "recents", "distinct", "protocols", "playing", "fileonly", "titleonly", "keywords"] + +#--Keybind that will be used to go to the next available filter based on the filters_and_sequence +next_filter_sequence_keybind=["RIGHT", "MBTN_FORWARD"] + +#--Keybind that will be used to go to the previous available filter based on the filters_and_sequence +previous_filter_sequence_keybind=["LEFT", "MBTN_BACK"] + +#--yes is for bypassing the last filter to go to first filter when navigating through filters using arrow keys, and vice-versa. no disables this behavior. +loop_through_filters=yes + +#--Create a filter out of your desired "keywords", e.g.: youtube.com will filter out the videos from youtube. You can also insert a portion of filename or title, or extension or a full path / portion of a path. e.g.: ["youtube.com", "mp4", "naruto", "c:\\users\\eisa01\\desktop"] +keywords_filter_list=[""] + +####------Sort Settings-------#### +##--available sort: added-asc is for the newest added item to show first. Or added-desc for the newest added to show last. Or alphanum-asc is for A to Z approach with filename and episode number lower first. Or alphanum-desc is for its Z to A approach. Or time-asc, time-desc to sort the list based on time. + +#--the default sorting method for all the different filters in the list. select between: added-asc, added-desc, time-asc, time-desc, alphanum-asc, alphanum-desc +list_default_sort=added-asc + +#--Default sort for specific filters, e.g.: [ ["all", "alphanum-asc"], ["playing", "added-desc"] ] +list_filters_sort=[ ] + +#--Keybind to cycle through the different available sorts when list is open +list_cycle_sort_keybind=["alt+s", "alt+S"] + +####------List Design Settings------#### + +#--The alignment for the list, uses numpad positions choose from 1-9 or 0 to disable. e,g.:7 top left alignment, 8 top middle alignment, 9 top right alignment. +list_alignment=7 + +#--The time type for items on the list. Select between: duration, length, remaining. +text_time_type=duration + +#--Time seperator that will be used before the saved time +time_seperator= 🕒 + +#--The text that indicates there are more items above. \N is for new line. \h is for hard space. +list_sliced_prefix=...\h\N\N + +#--The text that indicates there are more items below +list_sliced_suffix=... + +#--yes enables pre text for showing quickselect keybinds before the list. no to disable +quickselect_0to9_pre_text=no + +#--Text color for list in BGR hexadecimal +text_color=ffffff + +#--Font size for the text of list +text_scale=50 + +#--Black border size for the text of list +text_border=0.7 + +#--Text color of current cursor position in BGR +text_cursor_color=ffbf7f + +#--Font size for text of current cursor position in list +text_cursor_scale=50 + +#--Black border size for text of current cursor position in list +text_cursor_border=0.7 + +#--Pre text for highlighted multi-select item +text_highlight_pre_text=✅ + +#--Search color when in typing mode +search_color_typing=ffffaa + +#--Search color when not in typing mode and it is active +search_color_not_typing=56ffaa + +#--Header color in BGR hexadecimal +header_color=56ffaa + +#--Header text size for the list +header_scale=55 + +#--Black border size for the Header of list +header_border=0.8 + +#--Text to be shown as header for the list +#--Available header variables: %cursor%, %total%, %highlight%, %filter%, %search%, %listduration%, %listlength%, %listremaining% +#--User defined text that only displays if a variable is triggered: %prefilter%, %afterfilter%, %prehighlight%, %afterhighlight% %presearch%, %aftersearch%, %prelistduration%, %afterlistduration%, %prelistlength%, %afterlistlength%, %prelistremaining%, %afterlistremaining% +#--Variables explanation: %cursor: displays the number of cursor position in list. %total: total amount of items in current list. %highlight%: total number of highlighted items. %filter: shows the filter name, %search: shows the typed search. Example of user defined text that only displays if a variable is triggered of user: %prefilter: user defined text before showing filter, %afterfilter: user defined text after showing filter. + +header_text=📋 Clipboard [%cursor%/%total%]%prehighlight%%highlight%%afterhighlight%%prefilter%%filter%%afterfilter%%presort%%sort%%aftersort%%presearch%%search%%aftersearch% + +#--Sort method that is hidden from header when using %sort% variable +header_sort_hide_text=added-asc + +#--Text to be shown before or after triggered variable in the header +header_sort_pre_text= \{ +header_sort_after_text=} +header_filter_pre_text= [Filter: +header_filter_after_text=] +header_search_pre_text=\h\N\N[Search= +header_search_after_text=..] +header_highlight_pre_text=✅ +header_highlight_after_text= +header_list_duration_pre_text= 🕒 +header_list_duration_after_text= +header_list_length_pre_text= 🕒 +header_list_length_after_text= +header_list_remaining_pre_text= 🕒 +header_list_remaining_after_text= + +#--Copy seperator that will be shown for copied items in the list +copy_seperator= © + +#--Paste seperator that will be shown for pasted item in the list +paste_seperator= ℗ + +####-----Time Format Settings-----#### +##--in the first parameter, you can define from the available styles: default, hms, hms-full, timestamp, timestamp-concise "default" to show in HH:MM:SS.sss format. "hms" to show in 1h 2m 3.4s format. "hms-full" is the same as hms but keeps the hours and minutes persistent when they are 0. "timestamp" to show the total time as timestamp 123456.700 format. "timestamp-concise" shows the total time in 123456.7 format (shows and hides decimals depending on availability). +##--in the second parameter, you can define whether to show milliseconds, round them or truncate them. Available options: 'truncate' to remove the milliseconds and keep the seconds. 0 to remove the milliseconds and round the seconds. 1 or above is the amount of milliseconds to display. The default value is 3 milliseconds. +##--in the third parameter you can define the seperator between hour:minute:second. "default" style is automatically set to ":", "hms", "hms-full" are automatically set to " ". You can define your own. Some examples: ["default", 3, "-"],["hms-full", 5, "."],["hms", "truncate", ":"],["timestamp-concise"],["timestamp", 0],["timestamp", "truncate"],["timestamp", 5] + +copy_time_format=["timestamp-concise"] +osd_time_format=["default", "truncate"] +list_time_format=["default", "truncate"] +header_duration_time_format=["hms", "truncate", ":"] +header_length_time_format=["hms", "truncate", ":"] +header_remaining_time_format=["hms", "truncate", ":"] + +####------List Keybind Settings------#### +#--Add below (after a comma) any additional keybind you want to bind. Or change the letter inside the quotes to change the keybind +#--Example of changing and adding keybinds: --From ["b", "B"] To ["b"]. --From [""] to ["alt+b"]. --From [""] to ["a" "ctrl+a", "alt+a"] + +#--Keybind that will be used to navigate up on the list +list_move_up_keybind=["UP", "WHEEL_UP"] + +#--Keybind that will be used to navigate down on the list +list_move_down_keybind=["DOWN", "WHEEL_DOWN"] + +#--Keybind that will be used to go to the first item for the page shown on the list +list_page_up_keybind=["PGUP"] + +#--Keybind that will be used to go to the last item for the page shown on the list +list_page_down_keybind=["PGDWN"] + +#--Keybind that will be used to navigate to the first item on the list +list_move_first_keybind=["HOME"] + +#--Keybind that will be used to navigate to the last item on the list +list_move_last_keybind=["END"] + +#--Keybind that will be used to highlight while pressing a navigational keybind, keep holding shift and then press any navigation keybind, such as: up, down, home, pgdwn, etc.. +list_highlight_move_keybind=["SHIFT"] + +#--Keybind that will be used to highlight all displayed items on the list +list_highlight_all_keybind=["ctrl+a", "ctrl+A"] + +#--Keybind that will be used to remove all currently highlighted items from the list +list_unhighlight_all_keybind=["ctrl+d", "ctrl+D"] + +#--Keybind that will be used to load entry based on cursor position +list_select_keybind=["ENTER", "MBTN_MID"] + +#--Keybind that will be used to add entry to playlist based on cursor position +list_add_playlist_keybind=["CTRL+ENTER"] + +#--Keybind that will be used to add all highlighted entries to playlist +list_add_playlist_highlighted_keybind=["SHIFT+ENTER"] + +#--Keybind that will be used to close the list (closes search first if it is open) +list_close_keybind=["ESC", "MBTN_RIGHT"] + +#--Keybind that will be used to delete the entry based on cursor position +list_delete_keybind=["DEL"] + +#--Keybind that will be used to delete all highlighted entries from the list +list_delete_highlighted_keybind=["SHIFT+DEL"] + +#--Keybind that will be used to trigger search +list_search_activate_keybind=["ctrl+f", "ctrl+F"] + +#--Keybind that will be used to exit typing mode of search while keeping search open +list_search_not_typing_mode_keybind=["ALT+ENTER"] + +#--Keybind thats are ignored when list is open +list_ignored_keybind=["h", "H", "r", "R", "b", "B", "k", "K"] + +######----End of Settings----###### diff --git a/mac/.config/mpv/script-opts/SmartSkip.conf b/mac/.config/mpv/script-opts/SmartSkip.conf new file mode 100644 index 0000000..7d9b33a --- /dev/null +++ b/mac/.config/mpv/script-opts/SmartSkip.conf @@ -0,0 +1,221 @@ +######----Settings For SmartSkip 1.2----###### +####------Script Settings-----#### + + +####------Silence Skip Settings-------#### +#--(#number). Maximum amount of noise to trigger, in terms of dB. Lower is more sensitive. +silence_audio_level=-40 + +#--(#number). Duration of the silence that will be detected to trigger skipping. +silence_duration=0.65 + +#--(0/#number). The detected silences will be ignored for the below defined seconds, and it will continue skipping until a silence is detected that surpasses the ignore duration. +# (0 for disabled, or specify seconds). +ignore_silence_duration=5 + +#--(0/#number). Minimum amount of seconds accepted to skip until the configured silence_duration. +# (0 for disabled, or specify seconds) +min_skip_duration=0 + +#--(0/#number). Maximum amount of seconds accepted to skip until the configured silence_duration. +# (0 for disabled, or specify seconds) +max_skip_duration=130 + +#--(yes/no). Press keybind again while silence-skip is active to cancel +keybind_twice_cancel_skip=yes + +#--(playlist-next/cancel/pause). If skip reaches playback end, and there is no silence detected. +# (playlist-next: continues towards next playlist item / cancel: cancels seek and resumes from original position / pause: reaches end and pauses). +silence_skip_to_end=playlist-next + +#-- After detecing silence, a chapter will be added. +# (yes/no). Or specify types ["no-chapters", "internal-chapters", "external-chapters"]. +# e.g.: add_chapter_on_skip=["no-chapters", "external-chapters"] +add_chapter_on_skip=yes + +#--(yes/no). Default is muted, however if audio was enabled due to custom mpv settings, the fast-forwarded audio can sound jarring. +force_mute_on_skip=no + + +####------Smart Skip Settings-------#### +#--The skip behavior after passing the last chapter +# (defaults to silence-skip if not defined for a chapter type) +# Available chapter types (no-chapters, internal-chapters, external-chapters) +# Available skip types (silence-skip, chapter-next, playlist-next) +# e.g.: last_chapter_skip_behavior=[ ["no-chapters", "silence-skip"], ["internal-chapters", "playlist-next"], ["external-chapters", "chapter-next"] ]. +last_chapter_skip_behavior=[ ["no-chapters", "silence-skip"], ["internal-chapters", "playlist-next"], ["external-chapters", "silence-skip"] ] + +#--(yes/no). During autoskip countdown, pressing smart_next keybind will auto-skip +smart_next_proceed_countdown=yes + +#--(yes/no). During autoskip countdown, pressing smart_prev keybind will cancel the countdown to autoskip +smart_prev_cancel_countdown=yes + +####------Chapters Settings-------#### +#--(yes/no). Automatically loads external chapters when detected. +# priority: 1. chapters file in the same directory as the playing file, 2. hashed version of the chapters file in the global directory, 3. path based version of the chapters file in the global directory +external_chapters_autoload=yes + +#-- Modifying chapters using SmartSkip will autosave as external-chapters +# (yes/no). Or specify types ["no-chapters", "internal-chapters", "external-chapters"] +modified_chapters_autosave=["no-chapters", "external-chapters"] + +#--(yes/no). (yes: Saves all chapter files in a single global directory). (no: saves chapter next to the playback file) +global_chapters=yes + +#--(path) Global path for storing external chapters +#- Predefined directories (/:dir%script%, /:dir%mpvconf%) +# (/:dir%script%) for placing it in the same directory of script, +# (/:dir%mpvconf%) for mpv portable_config directory +#- Or write any variable using "/:var" then the variable +# (/:var) example: "/:var%APPDATA%", such as: "/:var%APPDATA%\mpv" OR "/:var%HOME%/mpv" +#- It is also possible to mix and match path with predefined directories / variables +global_chapters_path=/:dir%mpvconf%/chapters + +#--(yes/no). Hashes the chapters saved in the global directory, allowing for multiple files with the same name to have external chapters. +hash_global_chapters=yes + +#--(yes/no). (yes: Adding chapter asks for title) (no: Adds chapter without name) +add_chapter_ask_title=no + +#--(yes/no). Pauses the playback when asking for chapter title +add_chapter_pause_for_input=no + +#--(text). Placeholder when asking for title of a new chapter +add_chapter_placeholder_title=Chapter + +####------Auto-Skip Settings-------#### + +#--(yes/no). Allows for chapters to be skipped automatically based on configured categories and skip +autoskip_chapter=yes + +#--(0/#number). Countdown in seconds before initiating autoskip +# (0 disables countdown and immediately initiates autoskip, or specify countdown in seconds) +autoskip_countdown=3 + +#--(yes/no). Bulk consecutive autoskip chapters together in 1 countdown +autoskip_countdown_bulk=no + +#--(yes/no). Sends prompt for autoskip without forcing +autoskip_countdown_graceful=no + +#-- Detected autoskip will be triggered once only for each chapter +# (yes/no). Or specify types ["internal-chapters", "external-chapters"] +skip_once=no + +#--(string) write the string for any chapter type following the syntax: +# e.g.: categories=my-new-category>Part [AB]/Ending 1; another-category>Part C/Part D +#-- Or specify categories string for each chapter type. +# e.g.: categories=[ ["internal-chapters", "my-new-category>Part [AB]/Ending 1; another-category>Part C/Part D"], ["external-chapters", "idx->0/1/2/3"] ] +# Syntax: category-name-1>chapter-name-1-slash-separated/chapter-name-2-slash-separated; lua patterns, each category is separated by semicolons +# Predefined Category: (idx- followed by the chapter index / slash separated to autoskip based on index) +categories=[ ["internal-chapters", "prologue>Prologue/^Intro; opening>^OP/ OP$/^Opening; ending>^ED/ ED$/^Ending; preview>Preview$"], ["external-chapters", "idx->0/2"] ] + +#--(string) enables defined categories for autoskip +# e.g: skip=opening;ending +# Or write the category names for each chapter type: +# e.g.: skip=[ ["internal-chapters", "prologue;ending"], ["external-chapters", "idx-"] ] ]] +# Syntax: To enable category for autoskip, write any defined category name inside the string, followed by a semicolons. + +##-- Predefined Categories: (toggle, toggle_idx, idx-) +# (idx- is for enabling index based autoskip). +# (toggle is for chapters toggled during playback). +# (toggle_idx is for chapter index toggled during playback) + +skip=[ ["internal-chapters", "toggle;toggle_idx;opening;ending;preview"], ["external-chapters", "toggle;toggle_idx"] ] + +####------Autoload Settings-------#### +#--(yes/no). Autoload files in the same directory into playlist +autoload_playlist=no + +#--(#number). Max entries for autoload * 2 when starting a file (before + after). 5000 is the maximum recommended value +autoload_max_entries=5000 + +#--(#number). Max directory stack for autoload when starting a file. 20 is the recommended value +autoload_max_dir_stack=20 + +#--(yes/no). Hidden files in the directory will be ignored +ignore_hidden=yes + +#--(yes/no). Allow same type of extensions +same_type=no + +#--(auto/recursive/lazy/ignore). Specify the directory mode for autoload +directory_mode=auto + +#--(yes/no). Types that will be autoloaded +images=yes +videos=yes +audio=yes + +#--(extension,). Add additional extensions to be autoloaded. +# e.g.: additional_image_exts=bmp,vob,pxr +additional_image_exts= +additional_video_exts= +additional_audio_exts= + +####------OSD Messages Settings-------#### + +#--(-1/milliseconds). Duration for the osd message in milliseconds, applies to all osd_messages. +# -1 reverts to mpv --osd-duration +osd_duration=2500 + +#-- (no-osd/osd-bar/osd-msg/osd-msg-bar). OSD message that will be shown when the script triggers an action +seek_osd=osd-msg-bar +chapter_osd=osd-msg-bar +autoskip_osd=osd-msg-bar + +#--(yes/no). Shows OSD when playlist entry changes +playlist_osd=yes + +#--(yes/no). All other OSD messages. +osd_msg=yes + +####------Keybind Settings-------#### +#-- Add below (after a comma) any additional keybind you want to bind. Or change the letter inside the quotes to change the keybind +# e.g.: ["ctrl+x"], e.g.2: ["atl+n", "n", "N"] + +#-- Enables or disables autoload during playback for the session +toggle_autoload_keybind=[""] + +#-- Enables or disables Auto-Skip during playback for the session +toggle_autoskip_keybind=[""] + +#-- Enables or disables a chapter for Auto-Skip during playback for the session +toggle_category_autoskip_keybind=[""] + +#-- Cancels Auto-Skip when countdown is started +cancel_autoskip_countdown_keybind=["esc", "n"] + +#-- Proceeds Auto-Skip when countdown is started +proceed_autoskip_countdown_keybind=["enter", "y"] + +#-- Add a chapter for the reached position +add_chapter_keybind=[""] + +#-- Removes the current chapter +remove_chapter_keybind=[""] + +#-- Renames the current chapter +edit_chapter_keybind=[""] + +#-- Manually save the changes for chapters using an external file +write_chapters_keybind=[""] + +#-- Merge the changes of chapters for the file inside mkv file +bake_chapters_keybind=[""] + +#-- Jumps to next chapter > to next playlist +chapter_next_keybind=[""] + +#-- Jumps to previous chapter > to begining > to previous playlist +chapter_prev_keybind=[""] + +#-- Triggers silence_skip > next chapter > next playlist based on different variables +smart_next_keybind=[""] + +#-- Jumps to begining > previous chapter > previous playlist based on different variables +smart_prev_keybind=[""] + +#-- Triggers silence skip to detect silence as per the configured parameters +silence_skip_keybind=[""] diff --git a/mac/.config/mpv/script-opts/blur_edges.conf b/mac/.config/mpv/script-opts/blur_edges.conf new file mode 100644 index 0000000..b69ce99 --- /dev/null +++ b/mac/.config/mpv/script-opts/blur_edges.conf @@ -0,0 +1,26 @@ +# whether the script is active by default +active=yes + +# which black bars to replace with blur +# can be "all", "horizontal" or "vertical" +mode=all + +# intensity of the blur +# see the ffmpeg filter doc https://ffmpeg.org/ffmpeg-filters.html#boxblur +# tl;dr higher means blurrier +blur_radius=10 +blur_power=10 + +# the minimum size of the black bars for the effect to apply +minimum_black_bar_size=3 + +# if the aspect ratio of the video changes, we need to reapply the filter +# since this can happen very quickly, wait a short delay before doing it +reapply_delay=0.5 + +# until recently, resuming files that had the script active would unselect the video +# if your mpv version is more recent than feb 2 2018, you can set this to yes +watch_later_fix=no + +# only apply the blur effect when mpv is set to fullscreen +only_fullscreen=yes diff --git a/mac/.config/mpv/script-opts/command_palette.conf b/mac/.config/mpv/script-opts/command_palette.conf new file mode 100644 index 0000000..a913238 --- /dev/null +++ b/mac/.config/mpv/script-opts/command_palette.conf @@ -0,0 +1,19 @@ +font_size=40 +scale_by_window=no +lines_to_show=12 + +# might be buggy +#pause_on_open=no + +#resume_on_exit=only-if-was-paused + +# styles +#line_bottom_margin=1 +#menu_x_padding=5 +#menu_y_padding=2 + +# yes requires the MediaInfo CLI app being installed +use_mediainfo=yes + +#stream_quality_options=2160,1440,1080,720,480 +#aspect_ratios=4:3,16:9,2.35:1,1.36,1.82,0,-1 diff --git a/mac/.config/mpv/script-opts/gallery_worker.conf b/mac/.config/mpv/script-opts/gallery_worker.conf new file mode 100644 index 0000000..d56ab00 --- /dev/null +++ b/mac/.config/mpv/script-opts/gallery_worker.conf @@ -0,0 +1,18 @@ +# mpv-gallery-view | https://github.com/occivink/mpv-gallery-view +# This is the settings file for scripts/gallery-thumbgen.lua and its copies +# File placement: script-opts/gallery_worker.conf +# Defaults: https://github.com/occivink/mpv-gallery-view/blob/master/script-opts/gallery_worker.conf + +# accepts a |-separated list of URL patterns which gallery.lua should thumbnail using youtube-dl. +# The patterns are matched after the http(s):// part of the URL. +#^ matches the beginning of the URL, $ matches its end, and you should use % before any of the characters ^$()%|,.[]*+-? to match that character. +# +#Examples +# will exclude any URL that starts with http://youtube.com or https://youtube.com: +# ytdl_exclude=^youtube%.com +# will exclude any URL that ends with .mkv or .mp4: +# ytdl_exclude=%.mkv$|%.mp4$ +# See more lua patterns here: https://www.lua.org/manual/5.1/manual.html#5.4.1 +# +#See also: ytdl_hook-exclude in mpv's manpage. +ytdl_exclude= diff --git a/mac/.config/mpv/script-opts/mdmenu.conf b/mac/.config/mpv/script-opts/mdmenu.conf new file mode 100644 index 0000000..b8c00ac --- /dev/null +++ b/mac/.config/mpv/script-opts/mdmenu.conf @@ -0,0 +1,13 @@ +# if enabled, dmenu will be embedded inside the mpv instance. +# on older mpv versions (v0.35.0 and below) depends on +# [xdo](https://github.com/baskerville/xdo) to get mpv's xwindow id. +embed=yes + +# if enabled, the "current item" (e.g current chapter) will be preselected in dmenu. +# requires [preselect](https://tools.suckless.org/dmenu/patches/preselect/) +preselect=no + +# command that gets invoked. +# can be replaced with anything else that's "dmenu compliant" (such as rofi's dmenu mode). +# arguments are comma separated. +cmd=dmenu,-i,-l,16 diff --git a/mac/.config/mpv/script-opts/mpv_crop_script.conf b/mac/.config/mpv/script-opts/mpv_crop_script.conf new file mode 100644 index 0000000..32515b4 --- /dev/null +++ b/mac/.config/mpv/script-opts/mpv_crop_script.conf @@ -0,0 +1,5 @@ +output_template=~/Pictures/screenshots/mpv_${filename} ${#pos:%02h.%02m.%06.3s} ${!full:${crop_w}x${crop_h} ${%unique:%03d}}.${ext} +create_directories=yes +keep_original=no +disable_keybind=yes +warn_about_template=yes diff --git a/mac/.config/mpv/script-opts/playlist_view.conf b/mac/.config/mpv/script-opts/playlist_view.conf new file mode 100644 index 0000000..41efdd4 --- /dev/null +++ b/mac/.config/mpv/script-opts/playlist_view.conf @@ -0,0 +1,123 @@ +# mpv-gallery-view | https://github.com/occivink/mpv-gallery-view +# This is the settings file for scripts/playlist-view.lua +# File placement: script-opts/playlist_view.conf +# Defaults: https://github.com/occivink/mpv-gallery-view/blob/master/script-opts/playlist_view.conf + +# thumbnail directory in which to create and look for thumbnails +# on Unix-like platforms: +#thumbs_dir=~/.cache/thumbnails/mpv-gallery +# on Windows: +#thumbs_dir=%APPDATA%\mpv\gallery-thumbs-dir +# note that not all env vars get expanded, only '~' and 'APPDATA' do + +# create thumbs_dir if it doesn't exist +# mkdir_thumbs=yes + +# use mpv instead of ffmpeg for thumbnail generation +# slightly slower and does not support transparency, but does not require additional ffmpeg/ffprobe executables +# yes on Windows, no on other plateforms +#generate_thumbnails_with_mpv=no + +# all options below are platform-independent + +# fine-grained controls for the geometry of the gallery +# each option can have a fixed value, or dynamic by using the following variables: +# ww, wh: mpv window width, mpv window height (always available) +# gx, gy: gallery horizontal position, gallery vertical position +# gw, gh: gallery width, gallery height +# sw, sh: minimum spacing width, minimum spacing height +# tw, th: thumbnail width, thumbnail height +# these strings are interpreted using the lua equivalent of "eval" so math functions and logical conditions can be used +# if an option references variables, they will be computed in the appropriate order +# (for example, if gallery_width == 5 * thumbnail_width, thumbnail_size will be computed before gallery_size) +# in case of cyclical dependencies, the script will abort +# example +# ------- +# make the gallery centered +gallery_position={ (ww - gw) / 2, (wh - gh) / 2 } +# make the gallery's size 9/10 the size of the window +gallery_size={ 9 * ww / 10, 9 * wh / 10 } +# with at least 15 pixels of spacing between each thumbnail +min_spacing={ 15, 15 } +# and two thumbnail size presets for Windows smaller/bigger than 1366 x 768 +thumbnail_size=(ww * wh <= 1366 * 768) and {192, 108} or {288, 162} +# it is recommended to use discrete increments for thumbnail_size since a new thumbnail needs to be generated for each size + +# limit the number of thumbnails visible, even if more could be shown +# 64 is the maximum due to limitations in mpv +max_thumbnails=64 + +# the position in the file at which to take the thumbnail +# can either be a percentage of the video duration, or a number of seconds +take_thumbnail_at=20% + +# load to the selected video when the playlist-view is toggled off +load_file_on_toggle_off=no +# close the playlist-view when loading a video +close_on_load_file=yes +# pause the current video when the playlist-view is opened +pause_on_start=no +# resume the current video when the playlist-view is closed +# can be yes, no, or only-if-did-pause +# in the latter case, will only resume if the video was actually paused by opening the playlist-view +resume_on_stop=only-if-did-pause +# automatically start the playlist-view when mpv is started +start_on_mpv_startup=no +# automatically start the playlist-view when the current file is finished +# only has an effect when keep-open=always +start_on_file_end=no +# if the currently playing file changes, set the selection to the new one +follow_playlist_position=no +# when loading a file, remember the time-position of the previous +# and restart from there if it's loaded again +remember_time_position=yes + +# show the filename below each thumbnail +show_text=yes +# use the playlist title if it exists instead of the filename +show_title=yes +strip_directory=yes +strip_extension=yes +text_size=28 + +# colors are defined in hexadecimal in Blue Green Red (BGR) order +# if multiple colors should be active, they get evenly blended +# opacity is defined between 00 (opaque) and FF (transparent) +background_color=333333 +background_opacity=33 +normal_border_color=BBBBBB +normal_border_size=1 +selected_border_color=E5E4E5 +selected_border_size=6 +# show a special border around the currently playing file +highlight_active=yes +active_border_color=EBC5A7 +active_border_size=4 +flagged_border_color=96B58D +flagged_border_size=4 +placeholder_color=222222 + +# arbitrary commands that are run when the playlist-view is opened/closed +# this can be used for lowering video settings when the gallery is active, since +# high-quality video settings can result in slowdown of the gallery +command_on_open= +command_on_close= + +# the path of the 'flags' file that is written when you exit mpv +flagged_file_path=./mpv_gallery_flagged + +mouse_support=yes +UP=k +DOWN=j +LEFT=h +RIGHT=l +PAGE_UP=ctrl-u +PAGE_DOWN=ctrl-d +FIRST=0 +LAST=$ +RANDOM=r +ACCEPT=ENTER +CANCEL=ESC +# this only removes entries from the playlist, not the underlying file +REMOVE=DEL +FLAG=SPACE diff --git a/mac/.config/mpv/script-opts/thumbfast.conf b/mac/.config/mpv/script-opts/thumbfast.conf new file mode 100644 index 0000000..f0472e0 --- /dev/null +++ b/mac/.config/mpv/script-opts/thumbfast.conf @@ -0,0 +1,41 @@ +# Socket path (leave empty for auto) +socket= + +# Thumbnail path (leave empty for auto) +thumbnail= + +# Maximum thumbnail generation size in pixels (scaled down to fit) +# Values are scaled when hidpi is enabled +max_height=200 +max_width=200 + +# Scale factor for thumbnail display size (requires mpv 0.38+) +# Note that this is lower quality than increasing max_height and max_width +scale_factor=1 + +# Apply tone-mapping, no to disable +tone_mapping=auto + +# Overlay id +overlay_id=42 + +# Spawn thumbnailer on file load for faster initial thumbnails +spawn_first=no + +# Close thumbnailer process after an inactivity period in seconds, 0 to disable +quit_after_inactivity=0 + +# Enable on network playback +network=no + +# Enable on audio playback +audio=no + +# Enable hardware decoding +hwdec=no + +# Windows only: use native Windows API to write to pipe (requires LuaJIT) +direct_io=no + +# Custom path to the mpv executable +mpv_path=mpv diff --git a/mac/.config/mpv/scripts/Rename.lua b/mac/.config/mpv/scripts/Rename.lua new file mode 100644 index 0000000..381eea7 --- /dev/null +++ b/mac/.config/mpv/scripts/Rename.lua @@ -0,0 +1,99 @@ +-- Author: Kayizoku - https://github.com/Kayizoku/mpv-rename/edit/main/Rename.lua +local mp = require("mp") +local msg = require("mp.msg") +local utils = require("mp.utils") + +-- Variable to store the previous filename +local previousFilename = nil + +package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path +local input = require("user-input-module") + +local function rename(text, error) + if not text then + return msg.warn(error) + end + + local filepath = mp.get_property("path") + if filepath == nil then + return + end + + local directory, filename = utils.split_path(filepath) + local name, extension = filename:match("(%a*)%.([^%./]+)$") + if directory == "." then + directory = "" + end + local newfilepath = directory .. text + + -- Store the previous filename before renaming + previousFilename = filename + + msg.info(string.format("renaming '%s.%s' to '%s'", name, extension, text)) + local success, error = os.rename(filepath, newfilepath) + if not success then + msg.error(error) + mp.osd_message("rename failed") + return + end + + -- adding the new path to the playlist, and restarting the file with the correct path + mp.commandv("loadfile", newfilepath, "append") + mp.commandv( + "playlist-move", + mp.get_property_number("playlist-count", 2) - 1, + mp.get_property_number("playlist-pos", 1) + 1 + ) + mp.commandv("playlist-remove", "current") +end + +-- Function to undo the last rename operation +local function undoRename() + if previousFilename then + local filepath = mp.get_property("path") + local directory, _ = utils.split_path(filepath) + local previousFilepath = directory .. previousFilename + + local success, error = os.rename(filepath, previousFilepath) + if success then + msg.info("Undo rename successful") + mp.osd_message("Undo rename successful") + -- Update the playlist with the previous filepath + mp.commandv("loadfile", previousFilepath, "append") + mp.commandv( + "playlist-move", + mp.get_property_number("playlist-count", 2) - 1, + mp.get_property_number("playlist-pos", 1) + 1 + ) + mp.commandv("playlist-remove", "current") + -- Clear the previous filename variable + previousFilename = nil + else + msg.error("Failed to undo rename: " .. error) + mp.osd_message("Failed to undo rename") + end + else + msg.warn("No previous rename operation to undo") + mp.osd_message("No previous rename operation to undo") + end +end + +-- Bind a key to the undoRename function +mp.add_key_binding("U", "undo-rename", undoRename) + +-- Registering the event to cancel renaming if the file closes while renaming +mp.register_event("end-file", function() + input.cancel_user_input() +end) + +mp.add_key_binding("F2", "rename-file", function() + filepath = mp.get_property("path") + directory, filename = utils.split_path(filepath) + input.cancel_user_input() + input.get_user_input(rename, { + text = "Enter new filename:", + default_input = filename, + replace = false, + cursor_pos = filename:find("%.%w+$"), + }) +end) diff --git a/mac/.config/mpv/scripts/SimpleBookmark.lua b/mac/.config/mpv/scripts/SimpleBookmark.lua new file mode 100644 index 0000000..5af7f74 --- /dev/null +++ b/mac/.config/mpv/scripts/SimpleBookmark.lua @@ -0,0 +1,2907 @@ +-- Copyright (c) 2023, Eisa AlAwadhi +-- License: BSD 2-Clause License +-- Creator: Eisa AlAwadhi +-- Project: SimpleBookmark +-- Version: 1.3.1 + +local o = { +---------------------------USER CUSTOMIZATION SETTINGS--------------------------- +--These settings are for users to manually change some options. +--Changes are recommended to be made in the script-opts directory. + + -----Script Settings---- + --Available filters: 'all', 'keybinds', 'groups', 'recents', 'distinct', 'protocols', 'fileonly', 'titleonly', 'timeonly', 'keywords'. + --Filters description: "all" to display all the items. Or 'groups' to display the list filtered with items added to any group. Or 'keybinds' to display the list filtered with keybind slots. Or "recents" to display recently added items to log without duplicate. Or "distinct" to show recent saved entries for files in different paths. Or "fileonly" to display files saved without time. Or "timeonly" to display files that have time only. Or "keywords" to display files with matching keywords specified in the configuration. Or "playing" to show list of current playing file. + --Filters can also be stacked by using %+% or omitted by using %-%. e.g.: "groups%+%keybinds" shows only groups and keybinds, "all%-%groups%-%keybinds" shows all items without groups and without keybinds. + --Also defined groups can be called by using /:group%Group Name% + auto_run_list_idle = 'none', --Auto run the list when opening mpv and there is no video / file loaded. none for disabled. Or choose between available filters. + load_item_on_startup = 0, --runs a saved entry when mpv starts based on its number. -1 for oldest entry, 1 for latest entry, or select the number to load a specific entry, 0 for disabled + toggle_idlescreen = true, --hides OSC idle screen message when opening and closing menu (could cause unexpected behavior if multiple scripts are triggering osc-idlescreen off) + resume_offset = -0.65, --change to 0 so item resumes from the exact position, or decrease the value so that it gives you a little preview before loading the resume point + osd_messages = true, --true is for displaying osd messages when actions occur. Change to false will disable all osd messages generated from this script + bookmark_loads_last_idle = true, --When attempting to bookmark, if there is no video / file loaded, it will instead jump to your last bookmarked item and resume it. + bookmark_fileonly_loads_last_idle = true, --When attempting to bookmark fileonly, if there is no video / file loaded, it will instead jump to your last bookmarked item without resuming. + mark_bookmark_as_chapter = false, --true is for marking the time as a chapter. false disables mark as chapter behavior. + preserve_video_settings = false, --(true/false). Preserve video settings when bookmarking items and loading bookmarks by writing mpv watch-later config + bookmark_save_keybind=[[ + ["ctrl+b", "ctrl+B"] + ]], --Keybind that will be used to save the video and its time to log file + bookmark_fileonly_keybind=[[ + ["alt+b", "alt+B"] + ]], --Keybind that will be used to save the video without time to log file + open_list_keybind=[[ + [ ["b", "all"], ["B", "all"], ["k", "keybinds"], ["K", "keybinds"] ] + ]], --Keybind that will be used to open the list along with the specified filter. + list_filter_jump_keybind=[[ + [ ["b", "all"], ["B", "all"], ["k", "keybinds"], ["K", "keybinds"], ["!", "/:group%TV Shows%"], ["@", "/:group%Movies%"], ["SHARP", "/:group%Anime%"], ["$", "/:group%Anime Movies%"], ["%", "/:group%Cartoon%"], ["r", "recents"], ["R", "recents"], ["d", "distinct"], ["D", "distinct"], ["f", "fileonly"], ["F", "fileonly"] ] + ]], --Keybind that is used while the list is open to jump to the specific filter (it also enables pressing a filter keybind twice to close list). Available fitlers: 'all', 'keybinds', 'recents', 'distinct', 'protocols', 'fileonly', 'titleonly', 'timeonly', 'keywords'. + + -----Keybind Slots Settings----- + keybinds_quicksave_fileonly = true, --When quick saving to a keybind slot, it will not save position + keybinds_empty_auto_create = false, --If the keybind slot is empty, this enables quick logging and adding to slot, Otherwise keybinds are assigned from the list or via quicksave. + keybinds_empty_fileonly = true, --When auto creating keybind slot, it will not save position. + keybinds_auto_resume = true, --When loading a keybind slot, it will auto resume to the saved time. + keybinds_add_load_keybind=[[ + ["alt+1", "alt+2", "alt+3", "alt+4", "alt+5", "alt+6", "alt+7", "alt+8", "alt+9"] + ]], --Keybind that will be used to bind list item to a key, as well as to load it. e.g.: Press alt+1 on list cursor position to add it, press alt+1 while list is hidden to load item keybinded into alt+1. (A new slot is automatically created for each keybind. e.g: .."alt+9, alt+0". Where alt+0 creates a new 10th slot.) + keybinds_quicksave_keybind=[[ + ["alt+!", "alt+@", "alt+#", "alt+$", "alt+%", "alt+^", "alt+&", "alt+*", "alt+("] + ]], --To save keybind to a slot without opening the list, to load these keybinds it uses keybinds_add_load_keybind + keybinds_remove_keybind=[[ + ["alt+-"] + ]], --Keybind that is used when list is open to remove the keybind slot based on cursor position + keybinds_remove_highlighted_keybind=[[ + ["alt+_"] + ]], --Keybind that is used when list is open to remove the keybind slot based on highlighted items + + -----Group Settings----- + groups_list_and_keybind =[[ + [ ["TV Shows", "ctrl+1", "ctrl+!"], ["Movies", "ctrl+2", "ctrl+@"], ["Anime", "ctrl+3", "ctrl+#"], ["Anime Movies", "ctrl+4", "ctrl+$"], ["Cartoon", "ctrl+5"], ["Animated Movies"] ] + ]], --Define the groups that can be assigned to a bookmarked item, you can also optionally assign the keybind, and the highlight keybind that puts the bookmarked item into the relevant group when the list is open. Alternatively you can use list_group_add_cycle_keybind to assign item to a group + list_groups_remove_keybind=[[ + ["ctrl+-"] + ]], --Keybind that is used when list is open to remove the group based on cursor position + list_groups_remove_highlighted_keybind=[[ + ["ctrl+_"] + ]], --Keybind that is used when list is open to remove the group based on highlighted items + list_group_add_cycle_keybind=[[ + ["ctrl+g"] + ]], --Keybind to add an item to the group, this cycles through all the different available groups when list is open + list_group_add_cycle_highlighted_keybind=[[ + ["ctrl+G"] + ]], --Keybind to add highlighted items to the group, this cycles through all the different available groups when list is open + + -----Logging Settings----- + log_path = '/:dir%mpvconf%', --Change to '/:dir%script%' for placing it in the same directory of script, OR change to '/:dir%mpvconf%' for mpv portable_config directory. OR write any variable using '/:var' then the variable '/:var%APPDATA%' you can use path also, such as: '/:var%APPDATA%\\mpv' OR '/:var%HOME%/mpv' OR specify the absolute path , e.g.: 'C:\\Users\\Eisa01\\Desktop\\' + log_file = 'mpvBookmark.log', --name+extension of the file that will be used to store the log data + file_title_logging = 'all', --Change between 'all', 'protocols', 'local', 'none'. This option will store the media title in log file, it is useful for websites / protocols because title cannot be parsed from links alone + logging_protocols=[[ + ["https?://", "magnet:", "rtmp:"] + ]], --add above (after a comma) any protocol you want its title to be stored in the log file. This is valid only for (file_title_logging = 'protocols' or file_title_logging = 'all') + same_entry_limit = -1, --Limit saving entries with same path: -1 for unlimited, 0 will always update entries of same path, e.g. value of 3 will have the limit of 3 then it will start updating old values on the 4th entry. + overwrite_preserve_properties = true, --true is to preserve groups / slots or any other property when an entry is overwritten. + + -----List Settings----- + loop_through_list = false, --true is for going up on the first item loops towards the last item and vise-versa. false disables this behavior. + list_middle_loader = true, --false is for more items to show, then u must reach the end. true is for new items to show after reaching the middle of list. + quickselect_0to9_keybind = false, --Keybind entries from 0 to 9 for quick selection when list is open (list_show_amount = 10 is maximum for this feature to work) + main_list_keybind_twice_exits = true, --Will exit the list when double tapping the main list, even if the list was accessed through a different filter. + search_not_typing_smartly = true, --To smartly set the search as not typing (when search box is open) without needing to press ctrl+enter. + search_behavior = 'any', --'any' to find any typed search based on combination of date, title, path / url, and time. 'any-notime' to find any typed search based on combination of date, title, and path / url, but without looking for time (this is to reduce unwanted results). + + -----Filter Settings------ + filters_and_sequence=[[ + ["all", "keybinds", "groups", "/:group%TV Shows%", "/:group%Movies%", "/:group%Anime%", "/:group%Anime Movies%", "/:group%Cartoon%", "/:group%Animated Movies%", "protocols", "fileonly", "titleonly", "timeonly", "playing", "keywords", "recents", "distinct", "keybinds%+%groups", "all%-%groups%-%keybinds"] + ]], --Jump to the following filters and in the shown sequence when navigating via left and right keys. You can change the sequence and delete filters that are not needed. + next_filter_sequence_keybind=[[ + ["RIGHT", "MBTN_FORWARD"] + ]],--Keybind that will be used to go to the next available filter based on the filters_and_sequence + previous_filter_sequence_keybind=[[ + ["LEFT", "MBTN_BACK"] + ]],--Keybind that will be used to go to the previous available filter based on the filters_and_sequence + loop_through_filters = true, --true is for bypassing the last filter to go to first filter when navigating through filters using arrow keys, and vice-versa. false disables this behavior. + keywords_filter_list=[[ + [] + ]], --Create a filter out of your desired 'keywords', e.g.: youtube.com will filter out the videos from youtube. You can also insert a portion of filename or title, or extension or a full path / portion of a path. e.g.: ["youtube.com", "mp4", "naruto", "c:\\users\\eisa01\\desktop"]. To disable this filter keep it empty [] + + -----Sort Settings------ + --Available sorts: 'added-asc', 'added-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc' + --Sorts description: 'added-asc' is for the newest added item to show first. Or 'added-desc' for the newest added to show last. Or 'alphanum-asc' is for A to Z approach with filename and episode number lower first. Or 'alphanum-desc' is for its Z to A approach. Or 'time-asc', 'time-desc' to sort the list based on time. + list_default_sort = 'added-asc', --the default sorting method for all the different filters in the list. Choose between available sorts. + list_filters_sort=[[ + [ ["keybinds", "keybind-asc"], ["fileonly", "alphanum-asc"], ["playing", "time-asc"] ] + ]], --Default sort for specific filters, e.g.: [ ["all", "alphanum-asc"], ["playing", "added-desc"] ] + list_cycle_sort_keybind=[[ + ["alt+s", "alt+S"] + ]], --Keybind to cycle through the different available sorts when list is open + + -----List Design Settings----- + list_alignment = 7, --The alignment for the list, uses numpad positions choose from 1-9 or 0 to disable. e,g.:7 top left alignment, 8 top middle alignment, 9 top right alignment. + slice_name = false, --Change to true or false. Slices long names per the amount specified below + slice_name_amount = 55, --Amount for slicing long names (for path, name, and title) list_content_text variables + list_show_amount = 10, --Change maximum number to show items at once + list_content_text = '%number%. %name%%0_duration%%duration%%0_keybind%%keybind%%0_group%%group%%1_group%\\h\\N\\N', --Text to be shown as header for the list + --list_content_text variables: %quickselect%, %number%, %name%, %title%, %path%, %duration%, %length%, %remaining%, %dt%, %dt_"format%"% + --Variables explanation: %quickselect%: keybind for quickselect. %number%: numbered sequence of the item position. %name%: shows the file name. %title%: shows file title. %path%: shows the filepath or url. %duration%: the reached playback time of item. %length%: the total time length of the file. %remaining% the remaining playback time of file. %dt%: the logged date and time. + --You can also use %dt_"format%"%" as per lua date formatting (https://www.lua.org/pil/22.1.html). It is specified after dt_ ..example: (%dt_%a% %dt_%b% %dt_%y%) for abbreviated day month year + list_content_variables=[[ + [ ["0_duration", " 🕒 "], ["0_keybind", " ⌨ "], ["0_group", " 🖿 "] ] + ]], --User defined variables that only displays if the related variable is triggered. + --#_group, #_keybind, #_duration, #_length, #_remaining, #_dt. (# represents the possibility of creating many variables using different numbers. e.g.: "0_keybind", "1_keybind") + list_sliced_prefix = '...\\h\\N\\N', --The text that indicates there are more items above. \\N is for new line. \\h is for hard space. + list_sliced_suffix = '...', --The text that indicates there are more items below. + text_color = 'ffffff', --Text color for list in BGR hexadecimal + text_scale = 50, --Font size for the text of list + text_border = 0.7, --Black border size for the text of list + text_cursor_color = 'ffbf7f', --Text color of current cursor position in BGR hexadecimal + text_cursor_scale = 50, --Font size for text of current cursor position in list + text_cursor_border = 0.7, --Black border size for text of current cursor position in list + text_highlight_pre_text = '✅ ', --Pre text for highlighted multi-select item + search_color_typing = '00bfff', --Search color when in typing mode + search_color_not_typing = 'ffffaa', --Search color when not in typing mode and it is active + header_color = 'ffffaa', --Header color in BGR hexadecimal + header_scale = 55, --Header text size for the list + header_border = 0.8, --Black border size for the Header of list + header_text = '🔖 Bookmarks [%cursor%/%total%]%0_highlight%%highlight%%0_filter%%filter%%1_filter%%0_sort%%sort%%1_sort%%0_search%%search%%1_search%\\h\\N\\N', --The formatting of the items when you open the list + --header_text variables: %cursor%, %total%, %highlight%, %filter%, %search%, %duration%, %length%, %remaining%. + --Variables explanation: %cursor%: the number of cursor position. %total%: total amount in current list. %highlight%: total number of highlighted items. %filter%: shows the filter name, %search%: shows the typed search. %duration%: the total reached playback time of all displayed items. %length%: the total time length of the file for all displayed items. %remaining% the remaining playback time of file for all the displayed items. + header_variables=[[ + [ ["0_highlight", "✅"], ["0_filter", " [Filter: "], ["1_filter", "]"], ["0_sort", " \\{"], ["1_sort", "}"], ["0_search", "\\h\\N\\N[Search="], ["1_search", "..]"] ] + ]], --User defined variables that only displays if the related variable is triggered. + --#_filter, #_sort, #_highlight, #_search, #_duration, #_length%, #_remaining. (# represents the possibility of creating many variables using different numbers. e.g.: "0_filter", "1_filter") + header_sort_hide_text = 'added-asc',--Sort method that is hidden from header when using %sort% variable + + -----Time Format Settings----- + --in the first parameter, you can define from the available styles: default, hms, hms-full, timestamp, timestamp-concise "default" to show in HH:MM:SS.sss format. "hms" to show in 1h 2m 3.4s format. "hms-full" is the same as hms but keeps the hours and minutes persistent when they are 0. "timestamp" to show the total time as timestamp 123456.700 format. "timestamp-concise" shows the total time in 123456.7 format (shows and hides decimals depending on availability). + --in the second parameter, you can define whether to show milliseconds, round them or truncate them. Available options: 'truncate' to remove the milliseconds and keep the seconds. 0 to remove the milliseconds and round the seconds. 1 or above is the amount of milliseconds to display. The default value is 3 milliseconds. + --in the third parameter you can define the seperator between hour:minute:second. "default" style is automatically set to ":", "hms", "hms-full" are automatically set to " ". You can define your own. Some examples: ["default", 3, "-"],["hms-full", 5, "."],["hms", "truncate", ":"],["timestamp-concise"],["timestamp", 0],["timestamp", "truncate"],["timestamp", 5] + osd_time_format=[[ + ["default", "truncate"] + ]], + list_duration_time_format=[[ + ["default", "truncate"] + ]], + list_length_time_format=[[ + ["default", "truncate"] + ]], + list_remaining_time_format=[[ + ["default", "truncate"] + ]], + header_duration_time_format=[[ + ["hms", "truncate", ":"] + ]], + header_length_time_format=[[ + ["hms", "truncate", ":"] + ]], + header_remaining_time_format=[[ + ["hms", "truncate", ":"] + ]], + + -----List Keybind Settings----- + --Add below (after a comma) any additional keybind you want to bind. Or change the letter inside the quotes to change the keybind + --Example of changing and adding keybinds: --From ["b", "B"] To ["b"]. --From [""] to ["alt+b"]. --From [""] to ["a" "ctrl+a", "alt+a"] + list_move_up_keybind=[[ + ["UP", "WHEEL_UP"] + ]], --Keybind that will be used to navigate up on the list + list_move_down_keybind=[[ + ["DOWN", "WHEEL_DOWN"] + ]], --Keybind that will be used to navigate down on the list + list_page_up_keybind=[[ + ["PGUP"] + ]], --Keybind that will be used to go to the first item for the page shown on the list + list_page_down_keybind=[[ + ["PGDWN"] + ]], --Keybind that will be used to go to the last item for the page shown on the list + list_move_first_keybind=[[ + ["HOME"] + ]], --Keybind that will be used to navigate to the first item on the list + list_move_last_keybind=[[ + ["END"] + ]], --Keybind that will be used to navigate to the last item on the list + list_highlight_move_keybind=[[ + ["SHIFT"] + ]], --Keybind that will be used to highlight while pressing a navigational keybind, keep holding shift and then press any navigation keybind, such as: up, down, home, pgdwn, etc.. + list_highlight_all_keybind=[[ + ["ctrl+a", "ctrl+A"] + ]], --Keybind that will be used to highlight all displayed items on the list + list_unhighlight_all_keybind=[[ + ["ctrl+d", "ctrl+D"] + ]], --Keybind that will be used to remove all currently highlighted items from the list + list_select_keybind=[[ + ["ENTER", "MBTN_MID"] + ]], --Keybind that will be used to load entry based on cursor position + list_add_playlist_keybind=[[ + ["CTRL+ENTER"] + ]], --Keybind that will be used to add entry to playlist based on cursor position + list_add_playlist_highlighted_keybind=[[ + ["SHIFT+ENTER"] + ]], --Keybind that will be used to add all highlighted entries to playlist + list_close_keybind=[[ + ["ESC", "MBTN_RIGHT"] + ]], --Keybind that will be used to close the list (closes search first if it is open) + list_delete_keybind=[[ + ["DEL"] + ]], --Keybind that will be used to delete the entry based on cursor position + list_delete_highlighted_keybind=[[ + ["SHIFT+DEL"] + ]], --Keybind that will be used to delete all highlighted entries from the list + list_search_activate_keybind=[[ + ["ctrl+f", "ctrl+F"] + ]], --Keybind that will be used to trigger search + list_search_not_typing_mode_keybind=[[ + ["ALT+ENTER"] + ]], --Keybind that will be used to exit typing mode of search while keeping search open + list_ignored_keybind=[[ + ["h", "H", "r", "R", "c", "C"] + ]], --Keybind thats are ignored when list is open + +---------------------------END OF USER CUSTOMIZATION SETTINGS--------------------------- +} + +(require 'mp.options').read_options(o) +local utils = require 'mp.utils' +local msg = require 'mp.msg' + +o.filters_and_sequence = utils.parse_json(o.filters_and_sequence) +o.keywords_filter_list = utils.parse_json(o.keywords_filter_list) +o.list_filters_sort = utils.parse_json(o.list_filters_sort) +o.logging_protocols = utils.parse_json(o.logging_protocols) +o.osd_time_format = utils.parse_json(o.osd_time_format) +o.list_duration_time_format = utils.parse_json(o.list_duration_time_format)--1.3# changed and added time format for each in the list +o.list_length_time_format = utils.parse_json(o.list_length_time_format)--1.3# added time format for each in the list +o.list_remaining_time_format = utils.parse_json(o.list_remaining_time_format)--1.3# added time format for each in the list +o.header_duration_time_format = utils.parse_json(o.header_duration_time_format) +o.header_length_time_format = utils.parse_json(o.header_length_time_format) +o.header_remaining_time_format = utils.parse_json(o.header_remaining_time_format) +o.bookmark_save_keybind = utils.parse_json(o.bookmark_save_keybind) +o.bookmark_fileonly_keybind = utils.parse_json(o.bookmark_fileonly_keybind) +o.keybinds_add_load_keybind = utils.parse_json(o.keybinds_add_load_keybind) +o.keybinds_remove_keybind = utils.parse_json(o.keybinds_remove_keybind) +o.keybinds_remove_highlighted_keybind = utils.parse_json(o.keybinds_remove_highlighted_keybind) +o.keybinds_quicksave_keybind = utils.parse_json(o.keybinds_quicksave_keybind) +o.groups_list_and_keybind = utils.parse_json(o.groups_list_and_keybind) +o.list_groups_remove_keybind = utils.parse_json(o.list_groups_remove_keybind) +o.list_groups_remove_highlighted_keybind = utils.parse_json(o.list_groups_remove_highlighted_keybind) +o.list_group_add_cycle_keybind = utils.parse_json(o.list_group_add_cycle_keybind) +o.list_group_add_cycle_highlighted_keybind = utils.parse_json(o.list_group_add_cycle_highlighted_keybind) +o.list_move_up_keybind = utils.parse_json(o.list_move_up_keybind) +o.list_move_down_keybind = utils.parse_json(o.list_move_down_keybind) +o.list_page_up_keybind = utils.parse_json(o.list_page_up_keybind) +o.list_page_down_keybind = utils.parse_json(o.list_page_down_keybind) +o.list_move_first_keybind = utils.parse_json(o.list_move_first_keybind) +o.list_move_last_keybind = utils.parse_json(o.list_move_last_keybind) +o.list_highlight_move_keybind = utils.parse_json(o.list_highlight_move_keybind) +o.list_highlight_all_keybind = utils.parse_json(o.list_highlight_all_keybind) +o.list_unhighlight_all_keybind = utils.parse_json(o.list_unhighlight_all_keybind) +o.list_cycle_sort_keybind = utils.parse_json(o.list_cycle_sort_keybind) +o.list_content_variables = utils.parse_json(o.list_content_variables)--1.3# for new config +o.header_variables = utils.parse_json(o.header_variables)--1.3# for new config +o.list_select_keybind = utils.parse_json(o.list_select_keybind) +o.list_add_playlist_keybind = utils.parse_json(o.list_add_playlist_keybind) +o.list_add_playlist_highlighted_keybind = utils.parse_json(o.list_add_playlist_highlighted_keybind) +o.list_close_keybind = utils.parse_json(o.list_close_keybind) +o.list_delete_keybind = utils.parse_json(o.list_delete_keybind) +o.list_delete_highlighted_keybind = utils.parse_json(o.list_delete_highlighted_keybind) +o.list_search_activate_keybind = utils.parse_json(o.list_search_activate_keybind) +o.list_search_not_typing_mode_keybind = utils.parse_json(o.list_search_not_typing_mode_keybind) +o.next_filter_sequence_keybind = utils.parse_json(o.next_filter_sequence_keybind) +o.previous_filter_sequence_keybind = utils.parse_json(o.previous_filter_sequence_keybind) +o.open_list_keybind = utils.parse_json(o.open_list_keybind) +o.list_filter_jump_keybind = utils.parse_json(o.list_filter_jump_keybind) +o.list_ignored_keybind = utils.parse_json(o.list_ignored_keybind) + +if utils.shared_script_property_set then + utils.shared_script_property_set('simplebookmark-menu-open', 'no') +end +mp.set_property('user-data/simplebookmark/menu-open', 'no') + +if string.lower(o.log_path) == '/:dir%mpvconf%' then + o.log_path = mp.find_config_file('.') +elseif string.lower(o.log_path) == '/:dir%script%' then + o.log_path = debug.getinfo(1).source:match('@?(.*/)') +elseif o.log_path:match('/:var%%(.*)%%') then + local os_variable = o.log_path:match('/:var%%(.*)%%') + o.log_path = o.log_path:gsub('/:var%%(.*)%%', os.getenv(os_variable)) +end +local log_fullpath = utils.join_path(o.log_path, o.log_file) + +local log_length_text = 'length=' +local log_time_text = 'time=' +local log_keybind_text = 'slot=' +local log_group_text = 'group=' +local protocols = {'https?:', 'magnet:', 'rtmps?:', 'smb:', 'ftps?:', 'sftp:'} +local available_sorts = {'added-asc', 'added-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc'} +local search_string = '' +local search_active = false +local loadTriggered = false --1.3.0# to identify if load is triggered atleast once for idle option +local resume_selected = false +local osd_log_contents = {} +local list_start = 0 +local list_cursor = 1 +local list_highlight_cursor = {} +local list_drawn = false +local list_pages = {} +local filePath, fileTitle, fileLength +local seekTime = 0 +local filterName = 'all' +local sortName + +function starts_protocol(tab, val) + for index, value in ipairs(tab) do + if (val:find(value) == 1) then + return true + end + end + return false +end + +function contain_value(tab, val) + if not tab then return msg.error('check value passed') end + if not val then return msg.error('check value passed') end + + for index, value in ipairs(tab) do + if value.match(string.lower(val), string.lower(value)) then + return true + end + end + + return false +end + +function has_value(tab, val, array2d) + if not tab then return msg.error('check value passed') end + if not val then return msg.error('check value passed') end + if not array2d then + for index, value in ipairs(tab) do + if string.lower(value) == string.lower(val) then + return true + end + end + end + if array2d then + for i=1, #tab do + if tab[i] and string.lower(tab[i][array2d]) == string.lower(val) then + return true + end + end + end + + return false +end + +function file_exists(name) + local f = io.open(name, "r") + if f ~= nil then io.close(f) return true else return false end +end + +function format_time(seconds, sep, decimals, style) + local function divmod (a, b) + return math.floor(a / b), a % b + end + decimals = decimals == nil and 3 or decimals + + local s = seconds + local h, s = divmod(s, 60*60) + local m, s = divmod(s, 60) + + if decimals == 'truncate' then + s = math.floor(s) + decimals = 0 + if style == 'timestamp' then + seconds = math.floor(seconds) + end + end + + if not style or style == '' or style == 'default' then + local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals) + sep = sep and sep or ":" + return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s) + elseif style == 'hms' or style == 'hms-full' then + sep = sep ~= nil and sep or " " + if style == 'hms-full' or h > 0 then + return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s) + elseif m > 0 then + return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s) + else + return string.format("%." .. tostring(decimals) .. "fs", s) + end + elseif style == 'timestamp' then + return string.format("%." .. tostring(decimals) .. "f", seconds) + elseif style == 'timestamp-concise' then + return seconds + end +end + +function get_file() --1.3# removed prefer filename overtitle + local path = mp.get_property('path') + if not path then return end + + local length = (mp.get_property_number('duration') or 0) + + local title = mp.get_property('media-title'):gsub("\"", "") + + return path, title, length +end + +function get_local_names(target, property) --1.3# function to get names and fall back to whatever is found --1.2.4# removed or "" so that I can check for errors if the returned value is nil + local target_filename = target.found_name or target.found_title or target.found_path + local target_filepath = target.found_path or target.found_name or target.found_title + local target_filetitle = target.found_title or target.found_name or target.found_path + if not property then + return target_filename, target_filepath, target_filetitle + elseif property == 'osd' then --1.3# added osd property so it removes special character functions when displaying osd in mpv (uses gsub from search) + return esc_ass(target_filename), esc_ass(target_filepath), esc_ass(target_filetitle) + end +end + +function get_slot_keybind(keyindex) + local keybind_return + + if o.keybinds_add_load_keybind[keyindex] then + keybind_return = o.keybinds_add_load_keybind[keyindex] + else + keybind_return = log_keybind_text .. (keyindex or '') .. ' undefined' + end + + return keybind_return +end + +function get_group_properties(groupindex, action) + local gname, gkeybind, ghkeybind + + if o.groups_list_and_keybind[groupindex] and o.groups_list_and_keybind[groupindex][1] then + gname = o.groups_list_and_keybind[groupindex][1] + else + gname = log_group_text ..(groupindex or '').. ' undefined' + end + + if o.groups_list_and_keybind[groupindex] and o.groups_list_and_keybind[groupindex][2] then + gkeybind = o.groups_list_and_keybind[groupindex][2] + else + gkeybind = log_group_text ..(groupindex or '').. ' undefined' + end + + if o.groups_list_and_keybind[groupindex] and o.groups_list_and_keybind[groupindex][3] then + ghkeybind = o.groups_list_and_keybind[groupindex][3] + else + ghkeybind = log_group_text ..(groupindex or '').. ' undefined' + end + + return {name = gname, keybind = gkeybind, highlight_keybind = ghkeybind} +end + +function bind_keys(keys, name, func, opts) + if not keys then + mp.add_forced_key_binding(keys, name, func, opts) + return + end + + for i = 1, #keys do + if i == 1 then + mp.add_forced_key_binding(keys[i], name, func, opts) + else + mp.add_forced_key_binding(keys[i], name .. i, func, opts) + end + end +end + +function unbind_keys(keys, name) + if not keys then + mp.remove_key_binding(name) + return + end + + for i = 1, #keys do + if i == 1 then + mp.remove_key_binding(name) + else + mp.remove_key_binding(name .. i) + end + end +end + +function esc_string(str) + return str:gsub("([%p])", "%%%1") +end + +function esc_ass(str) --1.3# used function to escape, also this function will use the byte order mark instead of immediately pasting the zero-width space + return str:gsub('\\', '\\\239\187\191'):gsub('{', '\\{') +end + +---------Start of LogManager--------- +--LogManager (Read and Format the List from Log)-- +function read_log(func) + local f = io.open(log_fullpath, "r") + if not f then return end + local contents = {} + local line_count = 0 + for line in f:lines() do + table.insert(contents, (func(line))) + end + f:close() + return contents +end + +function read_log_table() + local line_pos = 0 + return read_log(function(line) + local tt, p, t, s, d, n, e, l, dt, ln, r, g + if line:match('^.-\"(.-)\"') then --1.3# changed if statement to only get title and path + tt, p = line:match('^.-\"(.-)\" | (.*) | ' .. esc_string(log_length_text) .. '(.*)') + else --1.3# get path only if no title is there + p = line:match('[(.*)%]]%s(.*) | ' .. esc_string(log_length_text) .. '(.*)') + end + d, n, e = p:match('^(.-)([^\\/]-)%.([^\\/%.]-)%.?$') --1.3# not inside if statement anymore since we are not changing name with title anymore + dt = line:match('%[(.-)%]') + t = line:match(' | ' .. esc_string(log_time_text) .. '(%d*%.?%d*)(.*)$') + ln = line:match(' | ' .. esc_string(log_length_text) .. '(%d*%.?%d*)(.*)$') + if tonumber(ln) and tonumber(t) then r = tonumber(ln) - tonumber(t) else r = 0 end + s = line:match(' | .* | ' .. esc_string(log_keybind_text) .. '(%d*)(.*)$') + g = line:match(' | .* | ' .. esc_string(log_group_text) .. '(%d*)(.*)$') + l = line + line_pos = line_pos + 1 + return {found_path = p, found_time = t, found_name = n, found_title = tt, found_line = l, found_sequence = line_pos, found_directory = d, found_datetime = dt, found_length = ln, found_remaining = r, found_slot = s, found_group = g} + end) +end + +function list_sort(tab, sort) + if sort == 'added-asc' then + table.sort(tab, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + elseif sort == 'added-desc' then + table.sort(tab, function(a, b) return a['found_sequence'] > b['found_sequence'] end) + elseif sort == 'keybind-asc' and filterName == 'keybinds' then + table.sort(tab, function(a, b) return a['found_slot'] > b['found_slot'] end) + elseif sort == 'keybind-desc' and filterName == 'keybinds' then + table.sort(tab, function(a, b) return a['found_slot'] < b['found_slot'] end) + elseif sort == 'time-asc' then + table.sort(tab, function(a, b) return tonumber(a['found_time']) > tonumber(b['found_time']) end) + elseif sort == 'time-desc' then + table.sort(tab, function(a, b) return tonumber(a['found_time']) < tonumber(b['found_time']) end) + elseif sort == 'alphanum-asc' or sort == 'alphanum-desc' then + 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 + if sort == 'alphanum-asc' then + table.sort(tab, function(a, b) return tostring(a['found_path']):gsub("%.?%d+", padnum) .. ("%3d"):format(#b) > tostring(b['found_path']):gsub("%.?%d+", padnum) .. ("%3d"):format(#a) end) + elseif sort == 'alphanum-desc' then + table.sort(tab, function(a, b) return tostring(a['found_path']):gsub("%.?%d+", padnum) .. ("%3d"):format(#b) < tostring(b['found_path']):gsub("%.?%d+", padnum) .. ("%3d"):format(#a) end) + end + end + + return tab +end + +function get_o_variable(str, arr_var) --1.3# function to get variable content from passed array + if not str then return end + if str:match('%%(.*)%%') then str = str:match('%%(.*)%%') end --1.3# if the entry has % around it, then remove it + + local var_return + for i = 1, #arr_var do --1.3# loop through the passed array and get the value of the matched variable + if arr_var[i][1] == str then + var_return = arr_var[i][2] --1.3# added or "" so if that the content of the variable is not defined it does not crash + break + end + end + + return var_return or "" --1.3# return the founded variable content or empty string if nothing is found + +end + +function parse_list_item(str, properties) --1.3#add ability to parse the contents of the list like the header + if not str then return msg.error('str in parse_list_item is nil') end + + local list_filename, list_filepath, list_filetitle = get_local_names(properties["item"],'osd')--1.3# added osd property so it removes special characters for displaying list + + if o.slice_name and list_filepath:len() > o.slice_name_amount then --1.3.1# fix #86 since p doesn't exist anymore, and checks for specific filename / filepath / filetitle, so slicing is accurate. + list_filepath = list_filepath:sub(1, o.slice_name_amount) .. "..." + end + if o.slice_name and list_filename:len() > o.slice_name_amount then + list_filename = list_filename:sub(1, o.slice_name_amount) .. "..." + end + if o.slice_name and list_filetitle:len() > o.slice_name_amount then + list_filetitle = list_filetitle:sub(1, o.slice_name_amount) .. "..." + end + + str = str:gsub("%%name%%", list_filename) + :gsub("%%path%%", list_filepath) + :gsub("%%title%%", list_filetitle) + :gsub("%%number%%", properties["index"]+1) --1.3# index +1 is the osd_index + :gsub("%%dt%%", properties["item"].found_datetime) + + for s in str:gmatch("%%dt_%%.%%") do --1.3# loop through all found dt_ in the script a + local svar = s:match("_%%."):sub(2) --1.3# match whatever starting from _ when using %dt_var%, then sub(2) to remove the first letter which is _ (then var will remain) to use in our gsub + if parse_8601(properties["item"].found_datetime) then --1.3.1# for backward compatibility if matching did not work reset to null + str = str:gsub(esc_string(s), os.date(svar, parse_8601(properties["item"].found_datetime))) --1.3# replaces the found dt_var with eg. dt_%a from config + + for x in str:gmatch("%%%d_dt%%") do --1.3.1# for backward compatibility adds 0_dt to be able to force customize date and time variables + str = str:gsub(esc_string(x), get_o_variable(x, o.list_content_variables)) + end + else --1.3.1# for backward compatibility if matching did not work reset to null + str = str:gsub(esc_string(s), "") + + for x in str:gmatch("%%%d_dt%%") do --1.3.1# for backward compatibility removes 0_dt if log time cannot be parsed from in log + str = str:gsub(esc_string(x), "") + end + end + end + + if properties["item"].found_slot then + str = str:gsub("%%keybind%%", get_slot_keybind(tonumber(properties["item"].found_slot))) + for s in str:gmatch("%%%d*_keybind%%") do --1.3# if a custom group variable is found, such as %0_group% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.list_content_variables)) + end + else + str = str:gsub("%%keybind%%", "") + for s in str:gmatch("%%%d*_keybind%%") do --1.3# if a custom slot is found and there is no slot assigned, remove it + str = str:gsub(esc_string(s), "") + end + end + + if properties["item"].found_group then + str = str:gsub("%%group%%", get_group_properties(tonumber(properties["item"].found_group)).name) + for s in str:gmatch("%%%d*_group%%") do --1.3# if a custom group variable is found, such as %0_group% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.list_content_variables)) + end + else + str = str:gsub("%%group%%", "") + for s in str:gmatch("%%%d*_group%%") do + str = str:gsub(esc_string(s), "") + end + end + + if properties['quickselect'] and str:match("%%quickselect%%") then --1.2# replace quickselect with the actual key if its available + str = str:gsub("%%quickselect%%", properties['quickselect']) + else + str = str:gsub("%%quickselect%%", "") + end + + --1.3# same concept for showing groups but for time + if properties["item"].found_time and tonumber(properties["item"].found_time) > 0 then + str = str:gsub('%%duration%%', format_time(properties["item"].found_time, o.list_duration_time_format[3], o.list_duration_time_format[2], o.list_duration_time_format[1])) + for s in str:gmatch("%%%d*_duration%%") do + str = str:gsub(esc_string(s), get_o_variable(s, o.list_content_variables)) + end + else + str = str:gsub("%%duration%%", "") + for s in str:gmatch("%%%d*_duration%%") do + str = str:gsub(esc_string(s), "") + end + end + if properties["item"].found_length and tonumber(properties["item"].found_length) > 0 then + str = str:gsub('%%length%%', format_time(properties["item"].found_length, o.list_length_time_format[3], o.list_length_time_format[2], o.list_length_time_format[1])) + for s in str:gmatch("%%%d*_length%%") do + str = str:gsub(esc_string(s), get_o_variable(s, o.list_content_variables)) + end + else + str = str:gsub("%%length%%", "") + for s in str:gmatch("%%%d*_length%%") do + str = str:gsub(esc_string(s), "") + end + end + if properties["item"].found_remaining and tonumber(properties["item"].found_remaining) > 0 then + str = str:gsub('%%remaining%%', format_time(properties["item"].found_remaining, o.list_remaining_time_format[3], o.list_remaining_time_format[2], o.list_remaining_time_format[1])) + for s in str:gmatch("%%%d*_remaining%%") do + str = str:gsub(esc_string(s), get_o_variable(s, o.list_content_variables)) + end + else + str = str:gsub("%%remaining%%", "") + for s in str:gmatch("%%%d*_remaining%%") do + str = str:gsub(esc_string(s), "") + end + end + str = str:gsub("%%%%", "%%") + + return str +end + +function parse_header(str) + local osd_header_color = string.format("{\\1c&H%s}", o.header_color) + local osd_search_color = osd_header_color + if search_active == 'typing' then + osd_search_color = string.format("{\\1c&H%s}", o.search_color_typing) + elseif search_active == 'not_typing' then + osd_search_color = string.format("{\\1c&H%s}", o.search_color_not_typing) + end + + str = str:gsub("%%total%%", #osd_log_contents) + :gsub("%%cursor%%", list_cursor) + + local filter_osd = filterName + if filter_osd ~= 'all' then + if filter_osd:match('/:group%%(.*)%%') then filter_osd = filter_osd:match('/:group%%(.*)%%') end + str = str:gsub("%%filter%%", filter_osd) + for s in str:gmatch("%%%d*_filter%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%filter%%", '') + for s in str:gmatch("%%%d*_filter%%") do --1.3# if a custom slot is found and there is no slot assigned, remove it + str = str:gsub(esc_string(s), "") + end + end + + if str:match('%%duration%%') then + if get_total_duration('found_time') > 0 then + str = str:gsub("%%duration%%", format_time(get_total_duration('found_time'), o.header_duration_time_format[3], o.header_duration_time_format[2], o.header_duration_time_format[1])) + for s in str:gmatch("%%%d*_duration%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%duration%%", '') + for s in str:gmatch("%%%d*_duration%%") do --1.3# if a custom slot is found and there is no slot assigned, remove it + str = str:gsub(esc_string(s), "") + end + end + end + + if str:match('%%length%%') then + if get_total_duration('found_length') > 0 then + str = str:gsub("%%length%%", format_time(get_total_duration('found_length'), o.header_length_time_format[3], o.header_length_time_format[2], o.header_length_time_format[1])) + for s in str:gmatch("%%%d*_length%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%length%%", '') + for s in str:gmatch("%%%d*_length%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), "") + end + end + end + + if str:match('%remaining%%') then + if get_total_duration('found_remaining') > 0 then + str = str:gsub("%%remaining%%", format_time(get_total_duration('found_remaining'), o.header_remaining_time_format[3], o.header_remaining_time_format[2], o.header_remaining_time_format[1])) + for s in str:gmatch("%%%d*_remaining%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%remaining%%", '') + for s in str:gmatch("%%%d*_remaining%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), "") + end + end + end + + if #list_highlight_cursor > 0 then + str = str:gsub("%%highlight%%", #list_highlight_cursor) + for s in str:gmatch("%%%d*_highlight%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%highlight%%", '') + for s in str:gmatch("%%%d*_highlight%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), "") + end + end + + if sortName and sortName ~= o.header_sort_hide_text then + str = str:gsub("%%sort%%", sortName) + for s in str:gmatch("%%%d*_sort%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%sort%%", '') + for s in str:gmatch("%%%d*_sort%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), "") + end + end + + if search_active then + local search_string_osd = search_string + if search_string_osd ~= '' then + search_string_osd = esc_ass(search_string:gsub('%%', '%%%%%%%%')) --1.3# used ass_escape instead of gsub + end + + str = str:gsub("%%search%%", osd_search_color..search_string_osd..osd_header_color) + for s in str:gmatch("%%%d*_search%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), get_o_variable(s, o.header_variables)) + end + else + str = str:gsub("%%search%%", '') + for s in str:gmatch("%%%d*_search%%") do --1.3# if a filter variable is found, such as %0_filter% then get the content of the variable for it + str = str:gsub(esc_string(s), "") + end + end + str = str:gsub("%%%%", "%%") + return str +end + +function search_log_contents(arr_contents) + if not arr_contents or not arr_contents[1] or not search_active or not search_string == '' then return false end + + local search_query = '' + for search in search_string:gmatch("[^%s]+") do + search_query = search_query..'.-'..esc_string(search) + end + local contents_string = '' + + local search_arr_contents = {} + + for i = 1, #arr_contents do --1.3# removed specific search method as it doesn't seem useful anymore, --1.3.1# utilize arr_contents instead of osd_log_contents + if o.search_behavior == 'any' then + contents_string = arr_contents[i].found_datetime --1.3# seperated date and time for search + if parse_8601(arr_contents[i].found_datetime) then --1.3# an if statement to check if date could be parsed --1.3# allows for all type of dates to be searched thanks to the loop + local os_date_tag= {'%a', '%A', '%b', '%B', '%c', '%d', '%H', '%I', '%M', '%m', '%p', '%S', '%w', '%x', '%X', '%Y', '%y'} --1.3# add all lua date time parameters + for j=1, #os_date_tag do --1.3# replace all lua parameters with date values that can be searched + contents_string = contents_string..os.date(os_date_tag[j], parse_8601(arr_contents[i].found_datetime)) + end + end + contents_string = contents_string..(arr_contents[i].found_title or '')..(arr_contents[i].found_name or '')..arr_contents[i].found_path --1.3# added found_name since parsing is different now + if tonumber(arr_contents[i].found_time) > 0 then + contents_string = contents_string..format_time(arr_contents[i].found_time, o.list_duration_time_format[3], o.list_duration_time_format[2], o.list_duration_time_format[1]) + end + if tonumber(arr_contents[i].found_length) > 0 then + contents_string = contents_string..format_time(arr_contents[i].found_length, o.list_length_time_format[3], o.list_length_time_format[2], o.list_length_time_format[1]) + end + if tonumber(arr_contents[i].found_remaining) > 0 then + contents_string = contents_string..format_time(arr_contents[i].found_remaining, o.list_remaining_time_format[3], o.list_remaining_time_format[2], o.list_remaining_time_format[1]) + end + if arr_contents[i].found_slot then + contents_string = contents_string..get_slot_keybind(tonumber(arr_contents[i].found_slot)) + end + if arr_contents[i].found_group then + contents_string = contents_string..get_group_properties(tonumber(arr_contents[i].found_group)).name + end + elseif o.search_behavior == 'any-notime' then + contents_string = arr_contents[i].found_datetime --1.3# seperated date and time for search + if parse_8601(arr_contents[i].found_datetime) then --1.3# an if statement to check if date could be parsed + local os_date_tag= {'%a', '%A', '%b', '%B', '%c', '%d', '%H', '%I', '%M', '%m', '%p', '%S', '%w', '%x', '%X', '%Y', '%y'} --1.3# add all lua date time parameters + for j=1, #os_date_tag do --1.3# replace all lua parameters with values that can be searched + contents_string = contents_string..os.date(os_date_tag[j], parse_8601(arr_contents[i].found_datetime)) + end + end + contents_string = contents_string..(arr_contents[i].found_title or '')..(arr_contents[i].found_name or '')..arr_contents[i].found_path --1.3# added found_name since parsing is different now + if arr_contents[i].found_slot then + contents_string = contents_string..get_slot_keybind(tonumber(arr_contents[i].found_slot)) + end + if arr_contents[i].found_group then + contents_string = contents_string..get_group_properties(tonumber(arr_contents[i].found_group)).name + end + end + + if string.lower(contents_string):match(string.lower(search_query)) then + table.insert(search_arr_contents, arr_contents[i]) + end + end + + return search_arr_contents + +end + +function filter_log_contents(arr_contents, filter) + if not arr_contents or not arr_contents[1] or not filter or filter == 'all' then return false end + local filtered_arr_contents = {} + + if filter:match('%%%+%%') then + if filter_stack(arr_contents,filter) then filtered_arr_contents = filter_stack(arr_contents, filter) end + elseif filter:match('%%%-%%') then + if filter_omit(arr_contents,filter) then filtered_arr_contents = filter_omit(arr_contents, filter) end + else + if filter_apply(arr_contents, filter) then filtered_arr_contents = filter_apply(arr_contents, filter) end + end + + return filtered_arr_contents +end + + +function filter_omit(arr_contents, filter) + if not arr_contents or not arr_contents[1] or not filter or filter == 'all' or not filter:match('%%%-%%') then return false end + local omitted_arr_table = arr_contents + + local filter_items = {} + for f in filter:gmatch("[^%%%-%%\r+]+") do + table.insert(filter_items, f) + end + + local temp_filtered_contents = arr_contents + for i=1, #filter_items do + if i== 1 and filter_apply(arr_contents, filter_items[i]) then omitted_arr_table = filter_apply(arr_contents, filter_items[i]) end + if i > 1 then + if filter_apply(arr_contents, filter_items[i]) then temp_filtered_contents = filter_apply(arr_contents, filter_items[i]) end + for j=1, #temp_filtered_contents do + for k=1, #omitted_arr_table do + if temp_filtered_contents[j] and omitted_arr_table[k] and temp_filtered_contents[j].found_sequence == omitted_arr_table[k].found_sequence then + table.remove(omitted_arr_table, k) + end + end + end + end + end + + table.sort(omitted_arr_table, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + + return omitted_arr_table +end + +function filter_stack(arr_contents, filter) + if not arr_contents or not arr_contents[1] or not filter or filter == 'all' or not filter:match('%%%+%%') then return false end + local stacked_arr_table = {} + local filter_items = {} + + for f in filter:gmatch("[^%%%+%%\r+]+") do + table.insert(filter_items, f) + end + + local unique_values = {} + local temp_filtered_contents = arr_contents + for i=1, #filter_items do + if filter_apply(arr_contents, filter_items[i]) then temp_filtered_contents = filter_apply(arr_contents, filter_items[i]) end + for j=1, #temp_filtered_contents do + if not has_value(unique_values, temp_filtered_contents[j].found_sequence) then + table.insert(unique_values, temp_filtered_contents[j].found_sequence) + table.insert(stacked_arr_table, temp_filtered_contents[j]) + end + end + end + table.sort(stacked_arr_table, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + + return stacked_arr_table + +end + +function filter_apply(arr_contents, filter) + if not arr_contents or not arr_contents[1] or not filter or filter == 'all' then return false end + local filtered_arr_contents = {} + + if filter == 'groups' then + for i = 1, #arr_contents do + if arr_contents[i].found_group then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter:match('/:group%%(.*)%%') then + filter = filter:match('/:group%%(.*)%%') + for i = 1, #arr_contents do + if arr_contents[i].found_group and filter == get_group_properties(tonumber(arr_contents[i].found_group)).name then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'keybinds' then + for i = 1, #arr_contents do + if arr_contents[i].found_slot then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'recents' then + table.sort(arr_contents, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + local unique_values = {} + local list_total = #arr_contents + + if filePath == arr_contents[#arr_contents].found_path and tonumber(arr_contents[#arr_contents].found_time) == 0 then + list_total = list_total -1 + end + + for i = list_total, 1, -1 do + if not has_value(unique_values, arr_contents[i].found_path) then + table.insert(unique_values, arr_contents[i].found_path) + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + table.sort(filtered_arr_contents, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + end + + if filter == 'distinct' then + table.sort(arr_contents, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + local unique_values = {} + local list_total = #arr_contents + + if filePath == arr_contents[#arr_contents].found_path and tonumber(arr_contents[#arr_contents].found_time) == 0 then + list_total = list_total -1 + end + + for i = list_total, 1, -1 do + if arr_contents[i].found_directory and not has_value(unique_values, arr_contents[i].found_directory) and not starts_protocol(protocols, arr_contents[i].found_path) then + table.insert(unique_values, arr_contents[i].found_directory) + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + table.sort(filtered_arr_contents, function(a, b) return a['found_sequence'] < b['found_sequence'] end) + end + + if filter == 'fileonly' then + for i = 1, #arr_contents do + if tonumber(arr_contents[i].found_time) == 0 then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'timeonly' then + for i = 1, #arr_contents do + if tonumber(arr_contents[i].found_time) > 0 then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'titleonly' then + for i = 1, #arr_contents do + if arr_contents[i].found_title then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'protocols' then + for i = 1, #arr_contents do + if starts_protocol(o.logging_protocols, arr_contents[i].found_path) then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'keywords' then + for i = 1, #arr_contents do + if contain_value(o.keywords_filter_list, arr_contents[i].found_line) then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + if filter == 'playing' then + for i = 1, #arr_contents do + if arr_contents[i].found_path == filePath then + table.insert(filtered_arr_contents, arr_contents[i]) + end + end + end + + return filtered_arr_contents +end + +function get_osd_log_contents(filter, sort) + if not filter then filter = filterName end + if not sort then sort = get_list_sort(filter) end + + local current_sort + osd_log_contents = read_log_table() + if not osd_log_contents or not osd_log_contents[1] then return end + + current_sort = 'added-asc' + + if filter_log_contents(osd_log_contents, filter) then osd_log_contents = filter_log_contents(osd_log_contents, filter) end + if search_log_contents(osd_log_contents) then osd_log_contents = search_log_contents(osd_log_contents) end + + if sort ~= current_sort then + list_sort(osd_log_contents, sort) + end +end + +function get_list_sort(filter) + if not filter then filter = filterName end + + if filter == 'keybinds' then + available_sorts = {'added-asc', 'added-desc', 'keybind-asc', 'keybind-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc'} + else + available_sorts = {'added-asc', 'added-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc'} + end + + local sort + for i=1, #o.list_filters_sort do + if o.list_filters_sort[i][1] == filter then + if has_value(available_sorts, o.list_filters_sort[i][2]) then sort = o.list_filters_sort[i][2] end + break + end + end + + if not sort and has_value(available_sorts, o.list_default_sort) then sort = o.list_default_sort end + + if not sort then sort = 'added-asc' end + + return sort +end + +function parse_8601(timestamp) + if not string.match(timestamp, '^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)(.-)$') then return false end + local inYear, inMonth, inDay, inHour, inMinute, inSecond, inZone = string.match(timestamp, '^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)(.-)$') + + local zHours, zMinutes = string.match(inZone, '^(.-):(%d%d)$') + + local returnTime = os.time({year=inYear, month=inMonth, day=inDay, hour=inHour, min=inMinute, sec=inSecond, isdst=false}) + + if zHours then + returnTime = returnTime - ((tonumber(zHours)*3600) + (tonumber(zMinutes)*60)) + end + + return returnTime + +end + +function draw_list(arr_contents) + local osd_msg = '' + local osd_color = '' + local key = 0 + local osd_text = string.format("{\\an%f{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", o.list_alignment, o.text_scale, o.text_scale, o.text_border, o.text_color) + local osd_cursor = string.format("{\\an%f}{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", o.list_alignment, o.text_cursor_scale, o.text_cursor_scale, o.text_cursor_border, o.text_cursor_color) + local osd_header = string.format("{\\an%f}{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", o.list_alignment, o.header_scale, o.header_scale, o.header_border, o.header_color) + local osd_msg_end = "{\\1c&HFFFFFF}" + local item_properties = {} --1.3# to hold all of the stuff that we extract from within this table, such as the osd_index, etc.. + + if o.header_text ~= '' then + osd_msg = osd_msg .. osd_header .. parse_header(o.header_text) .. osd_msg_end --1.3.0# made line break part of the config + end + + if search_active and not arr_contents[1] then --1.3.1# changed osd_log_contents to arr_contents + osd_msg = osd_msg .. 'No search results found' .. osd_msg_end + end + + local list_start + if o.list_middle_loader then + list_start = list_cursor - math.floor(o.list_show_amount / 2) + else + list_start = list_cursor - o.list_show_amount + end + local showall = false + local showrest = false + if list_start < 0 then list_start = 0 end + if #arr_contents <= o.list_show_amount then + list_start = 0 + showall = true + end + if list_start > math.max(#arr_contents - o.list_show_amount - 1, 0) then + list_start = #arr_contents - o.list_show_amount + showrest = true + end + if list_start > 0 and not showall then + osd_msg = osd_msg .. o.list_sliced_prefix .. osd_msg_end + end + for i = list_start, list_start + o.list_show_amount - 1, 1 do + if i == #arr_contents then break end + item_properties["item"] = arr_contents[#arr_contents - i] --1.3# stores the targetted item + item_properties['index'] = i --1.3# stores the index of which the item is found in the arr_contents table + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 then + key = key + 1 + if key == 10 then key = 0 end + item_properties['quickselect'] = key --1.3# added osd_key to item_properties to call it in parse_list_item + end + + if i + 1 == list_cursor then + osd_color = osd_cursor + else + osd_color = osd_text + end + + for j = 1, #list_highlight_cursor do + if list_highlight_cursor[j] and list_highlight_cursor[j][1] == i+1 then + osd_msg = osd_msg..osd_color..esc_string(o.text_highlight_pre_text) + end + end + + if o.list_content_text ~= '' then --1.3# use parse_list_item to make the list customizable + osd_msg = osd_msg..osd_color..parse_list_item(o.list_content_text, item_properties) .. osd_msg_end --1.3.0# made line break part of the config + end + + if i == list_start + o.list_show_amount - 1 and not showall and not showrest then + osd_msg = osd_msg .. o.list_sliced_suffix + end + + end + mp.set_osd_ass(0, 0, osd_msg) +end + +function list_empty_error_msg() + if osd_log_contents ~= nil and osd_log_contents[1] then return end + local msg_text + if filterName ~= 'all' then + msg_text = filterName .. " filter in Bookmark Empty" + else + msg_text = "Bookmark Empty" + end + msg.info(msg_text) + if o.osd_messages == true and not list_drawn then + mp.osd_message(msg_text) + end +end + +function display_list(filter, sort, action) + if not filter then filter = 'all' end + if not sortName then sortName = get_list_sort(filter) end + + local prev_sort = sortName + if not has_value(available_sorts, prev_sort) then prev_sort = get_list_sort() end + + if not sort then sort = get_list_sort(filter) end + sortName = sort + + local prev_filter = filterName + filterName = filter + + get_osd_log_contents(filter, sort) + + if action ~= 'hide-osd' then + if not osd_log_contents or not osd_log_contents[1] then + list_empty_error_msg() + filterName = prev_filter + get_osd_log_contents(filterName) + return + end + end + if not osd_log_contents and not search_active or not osd_log_contents[1] and not search_active then return end + + if not has_value(o.filters_and_sequence, filter) then + table.insert(o.filters_and_sequence, filter) + end + + local insert_new = false + + local trigger_close_list = false + local trigger_initial_list = false + + + if not list_pages or not list_pages[1] then + table.insert(list_pages, {filter, 1, 1, {}, sort}) + else + for i = 1, #list_pages do + if list_pages[i][1] == filter then + list_pages[i][3] = list_pages[i][3]+1 + insert_new = false + break + else + insert_new = true + end + end + end + + if insert_new then table.insert(list_pages, {filter, 1, 1, {}, sort}) end + + for i = 1, #list_pages do + if not search_active and list_pages[i][1] == prev_filter then + list_pages[i][2] = list_cursor + list_pages[i][4] = list_highlight_cursor + list_pages[i][5] = prev_sort + end + if list_pages[i][1] ~= filter then + list_pages[i][3] = 0 + end + if list_pages[i][3] == 2 and filter == 'all' and o.main_list_keybind_twice_exits then + trigger_close_list = true + elseif list_pages[i][3] == 2 and list_pages[1][1] == filter then + trigger_close_list = true + elseif list_pages[i][3] == 2 then + trigger_initial_list = true + end + end + + if trigger_initial_list then + display_list(list_pages[1][1], nil, 'hide-osd') + return + end + + if trigger_close_list then + list_close_and_trash_collection() + return + end + + if not search_active then get_page_properties(filter) else update_search_results('','') end + draw_list(osd_log_contents) + if utils.shared_script_property_set then + utils.shared_script_property_set('simplebookmark-menu-open', 'yes') + end + mp.set_property('user-data/simplebookmark/menu-open', 'yes') + if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'no', 'no_osd') end + list_drawn = true + if not search_active then get_list_keybinds() end +end + +--End of LogManager (Read and Format the List from Log)-- + +--LogManager Navigation-- +function select(pos, action) + if not search_active then + if not osd_log_contents or not osd_log_contents[1] then + list_close_and_trash_collection() + return + end + end + + local list_cursor_temp = list_cursor + pos + if list_cursor_temp > 0 and list_cursor_temp <= #osd_log_contents then + list_cursor = list_cursor_temp + + if action == 'highlight' then + if not has_value(list_highlight_cursor, list_cursor, 1) then + if pos > -1 then + for i = pos, 1, -1 do + if not has_value(list_highlight_cursor, list_cursor-i, 1) then + table.insert(list_highlight_cursor, {list_cursor-i, osd_log_contents[#osd_log_contents+1+i - list_cursor]}) + end + end + else + for i = pos, -1, 1 do + if not has_value(list_highlight_cursor, list_cursor-i, 1) then + table.insert(list_highlight_cursor, {list_cursor-i, osd_log_contents[#osd_log_contents+1+i - list_cursor]}) + end + end + end + table.insert(list_highlight_cursor, {list_cursor, osd_log_contents[#osd_log_contents+1 - list_cursor]}) + else + for i=1, #list_highlight_cursor do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor then + table.remove(list_highlight_cursor, i) + end + end + if pos > -1 then + for i=1, #list_highlight_cursor do + for j = pos, 1, -1 do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor-j then + table.remove(list_highlight_cursor, i) + end + end + end + else + for i=#list_highlight_cursor, 1, -1 do + for j = pos, -1, 1 do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor-j then + table.remove(list_highlight_cursor, i) + end + end + end + end + end + end + end + + if o.loop_through_list then + if list_cursor_temp > #osd_log_contents then + list_cursor = 1 + elseif list_cursor_temp < 1 then + list_cursor = #osd_log_contents + end + end + + draw_list(osd_log_contents) +end + +function list_move_up(action) + select(-1, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_down(action) + select(1, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_first(action) + select(1 - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_last(action) + select(#osd_log_contents - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_page_up(action) + select(list_start + 1 - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_page_down(action) + if o.list_middle_loader then + if #osd_log_contents < o.list_show_amount then + select(#osd_log_contents - list_cursor, action) + else + select(o.list_show_amount + list_start - list_cursor, action) + end + else + if o.list_show_amount > list_cursor then + select(o.list_show_amount - list_cursor, action) + elseif #osd_log_contents - list_cursor >= o.list_show_amount then + select(o.list_show_amount, action) + else + select(#osd_log_contents - list_cursor, action) + end + end + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_highlight_all() + get_osd_log_contents(filterName) + if not osd_log_contents or not osd_log_contents[1] then return end + + if #list_highlight_cursor < #osd_log_contents then + for i=1, #osd_log_contents do + if not has_value(list_highlight_cursor, i, 1) then + table.insert(list_highlight_cursor, {i, osd_log_contents[#osd_log_contents+1-i]}) + end + end + select(0) + else + list_unhighlight_all() + end +end + +function list_unhighlight_all() + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + list_highlight_cursor = {} + select(0) +end +--End of LogManager Navigation-- + +--LogManager Actions-- +function load(list_cursor, add_playlist, target_time) + if not osd_log_contents or not osd_log_contents[1] then return end + if not target_time then + if not osd_log_contents[#osd_log_contents - list_cursor + 1] then return end --1.3.0# fixes crash when loading an entry that doesn't exist + seekTime = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_time) + o.resume_offset + if (seekTime < 0) then + seekTime = 0 + end + else + seekTime = target_time + end + if file_exists(osd_log_contents[#osd_log_contents - list_cursor + 1].found_path) or starts_protocol(protocols, osd_log_contents[#osd_log_contents - list_cursor + 1].found_path) then + local list_filename, list_filepath, list_filetitle = get_local_names(osd_log_contents[#osd_log_contents - list_cursor + 1]) --1.3# use the name that automatically falls back instead for osd printing or msg (solves the issue that causes concatinating found_name to crash because it sometimes doesn't exist due to parsing changes) + if not add_playlist then + if o.preserve_video_settings then mp.command("write-watch-later-config") end--1.3.1# option to preserve video settings by using write-watch-later-config when loading bookmark replaces current file #84 + if filePath ~= osd_log_contents[#osd_log_contents - list_cursor + 1].found_path then + mp.commandv('loadfile', osd_log_contents[#osd_log_contents - list_cursor + 1].found_path) + resume_selected = true + else + mp.commandv('seek', seekTime, 'absolute', 'exact') + list_close_and_trash_collection() + end + if o.osd_messages == true then + mp.osd_message('Loaded:\n' .. list_filename.. ' 🕒 ' .. format_time(seekTime, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1])) + end + msg.info('Loaded the below file:\n' .. list_filename .. ' | '.. format_time(seekTime)) + else + mp.commandv('loadfile', osd_log_contents[#osd_log_contents - list_cursor + 1].found_path, 'append-play') + if o.osd_messages == true then + mp.osd_message('Added into Playlist:\n'..list_filename..' ') + end + msg.info('Added the below file into playlist:\n' .. list_filepath) + end + else + if o.osd_messages == true then + mp.osd_message('File Doesn\'t Exist:\n' .. osd_log_contents[#osd_log_contents - list_cursor + 1].found_path) --1.3# cant use the list_filepath because file doesn't exist so it returns nil + end + msg.info('The file below doesn\'t seem to exist:\n' .. osd_log_contents[#osd_log_contents - list_cursor + 1].found_path) + return + end +end + +function list_select() + load(list_cursor) +end + +function list_add_playlist(action) + if not action then + load(list_cursor, true) + elseif action == 'highlight' then + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + local file_ignored_total = 0 + + for i=1, #list_highlight_cursor do + if file_exists(list_highlight_cursor[i][2].found_path) or starts_protocol(protocols, list_highlight_cursor[i][2].found_path) then + mp.commandv("loadfile", list_highlight_cursor[i][2].found_path, "append-play") + else + msg.warn('The below file was not added into playlist as it does not seem to exist:\n' .. list_highlight_cursor[i][2].found_path) + file_ignored_total = file_ignored_total + 1 + end + end + if o.osd_messages == true then + if file_ignored_total > 0 then + mp.osd_message('Added into Playlist '..#list_highlight_cursor - file_ignored_total..' Item/s\nIgnored '..file_ignored_total.. " Item/s That Do Not Exist") + else + mp.osd_message('Added into Playlist '..#list_highlight_cursor - file_ignored_total..' Item/s') + end + end + if file_ignored_total > 0 then + msg.warn('Ignored a total of '..file_ignored_total.. " Item/s that does not seem to exist") + end + msg.info('Added into playlist a total of '..#list_highlight_cursor - file_ignored_total..' item/s') + end +end + +function same_path_log_delete(target_path, entry_limit, arr_contents) + --1.2.5# seperate function for entry_limit + if not target_path then return msg.error('same_path_log_delete no target_path defined') end + if not arr_contents then --1.2.6# ability to pass array (usually automatically defining array here is fine, but just for performance sake when calling multiple functions that use the same array) + arr_contents = read_log_table() + if not arr_contents or not arr_contents[1] then return end + end + + --1.2.7# return deleted_entries for o.overwrite_preserve_properties option + local deleted_entries = {} + local trigger_delete = false + + if entry_limit and entry_limit > -1 then + local entries_found = 0 + for i = #arr_contents, 1, -1 do + if arr_contents[i].found_path == target_path and entries_found < entry_limit then + entries_found = entries_found + 1 + elseif arr_contents[i].found_path == target_path and entries_found >= entry_limit then + table.insert(deleted_entries, arr_contents[i]) --1.2.7# store entries that will be deleted in a new array + table.remove(arr_contents,i) + trigger_delete = true + end + end + end + + if not trigger_delete then return end + local f = io.open(log_fullpath, "w+") + if arr_contents ~= nil and arr_contents[1] then + for i = 1, #arr_contents do + f:write(("%s\n"):format(arr_contents[i].found_line)) + end + end + f:close() + return deleted_entries +end + + +function find_entry(round, target_path, target_time) --1.2.6# changed it to find_entry to have the sequence and any other additional property + --1.2.5# get the entry log sequence which is basically the id using path and time + if not target_path or not target_time then return msg.error('find_entry no target_path or target_time defined') end + local temp_log_contents = read_log_table() + if not temp_log_contents or not temp_log_contents[1] then return end + + for i = #temp_log_contents, 1, -1 do + if not round then + if temp_log_contents[i].found_path == target_path and tonumber(temp_log_contents[i].found_time) == target_time then + return temp_log_contents[i] + end + else + if temp_log_contents[i].found_path == target_path and math.floor(tonumber(temp_log_contents[i].found_time)) == target_time then + return temp_log_contents[i] + end + end + end +end + + +function delete_log_entry(target_sequence, arr_contents) + --1.2.5# new function to delete based on sequence which is (id) + if not target_sequence then return end --1.2.5# if no sequence found then just exit the function + if not arr_contents then --1.2.5# ability to pass an array instead of looping through + arr_contents = read_log_table() + if not arr_contents or not arr_contents[1] then return end + end + + table.remove(arr_contents, target_sequence) + + local f = io.open(log_fullpath, "w+") + if arr_contents ~= nil and arr_contents[1] then + for i = 1, #arr_contents do + f:write(("%s\n"):format(arr_contents[i].found_line)) + end + end + f:close() +end + +function delete_log_entry_highlighted() + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + local temp_log_contents = read_log_table() + if not temp_log_contents or not temp_log_contents[1] then return end + + local log_contents_length = #temp_log_contents + + for i = 1, log_contents_length do + for j=1, #list_highlight_cursor do + if temp_log_contents[log_contents_length+1-i] then + if temp_log_contents[log_contents_length+1-i].found_sequence == list_highlight_cursor[j][2].found_sequence then + table.remove(temp_log_contents, log_contents_length+1-i) + end + end + end + end + + msg.info("Deleted "..#list_highlight_cursor.." Item/s") + + list_unhighlight_all() + + local f = io.open(log_fullpath, "w+") + if temp_log_contents ~= nil and temp_log_contents[1] then + for i = 1, #temp_log_contents do + f:write(("%s\n"):format(temp_log_contents[i].found_line)) + end + end + f:close() + +end + +function delete_selected() + --1.2.5# replace with new delete_log_entry that uses sequence id, and used local variables with or statement just in case + local list_sequence = osd_log_contents[#osd_log_contents - list_cursor + 1].found_sequence + local list_filepath = osd_log_contents[#osd_log_contents - list_cursor + 1].found_path or "" + local list_seektime = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_time) or 0 + + if not list_sequence then + msg.info("Failed to delete") + return + end + delete_log_entry(list_sequence) + msg.info("Deleted \"" .. list_filepath .. "\" | " .. format_time(list_seektime)) +end + +function list_delete(action) + if not action then + delete_selected() + elseif action == 'highlight' then + delete_log_entry_highlighted() + end + get_osd_log_contents() + if #osd_log_contents == 0 then + display_list('all') + select(0) + elseif list_cursor < #osd_log_contents + 1 then + select(0) + else + list_move_last() + end +end + +function get_total_duration(action) + if not osd_log_contents or not osd_log_contents[1] then return 0 end + local list_total_duration = 0 + if action == 'found_time' or action == 'found_length' or action == 'found_remaining' then + for i = #osd_log_contents, 1, -1 do + if tonumber(osd_log_contents[i][action]) > 0 then + list_total_duration = list_total_duration + osd_log_contents[i][action] + end + end + end + return list_total_duration +end + +function list_cycle_sort() + if filterName == 'keybinds' then + available_sorts = {'added-asc', 'added-desc', 'keybind-asc', 'keybind-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc'} + else + available_sorts = {'added-asc', 'added-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc'} + end + + local next_sort + for i = 1, #available_sorts do + if sortName == available_sorts[i] then + if i == #available_sorts then + next_sort = available_sorts[1] + break + else + next_sort = available_sorts[i+1] + break + end + end + end + if not next_sort then return end + get_osd_log_contents(filterName, next_sort) + sortName = next_sort + update_list_highlist_cursor() + select(0) +end + +function update_list_highlist_cursor() + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + + local temp_list_highlight_cursor = {} + for i = 1, #osd_log_contents do + for j=1, #list_highlight_cursor do + if osd_log_contents[#osd_log_contents+1-i].found_sequence == list_highlight_cursor[j][2].found_sequence then + table.insert(temp_list_highlight_cursor, {i, list_highlight_cursor[j][2]}) + end + end + end + + list_highlight_cursor = temp_list_highlight_cursor +end + +--End of LogManager Actions-- + +--LogManager Filter Functions-- +function get_page_properties(filter) + if not filter then return end + for i=1, #list_pages do + if list_pages[i][1] == filter then + list_cursor = list_pages[i][2] + list_highlight_cursor = list_pages[i][4] + sortName = list_pages[i][5] + end + end + if list_cursor > #osd_log_contents then + list_move_last() + end +end + +function select_filter_sequence(pos) + if not list_drawn then return end + local curr_pos + local target_pos + + for i = 1, #o.filters_and_sequence do + if filterName == o.filters_and_sequence[i] then + curr_pos = i + end + end + + if curr_pos and pos > -1 then + for i = curr_pos, #o.filters_and_sequence do + if o.filters_and_sequence[i + pos] then + get_osd_log_contents(o.filters_and_sequence[i + pos]) + if osd_log_contents ~= nil and osd_log_contents[1] then + target_pos = i + pos + break + end + end + end + elseif curr_pos and pos < 0 then + for i = curr_pos, 0, -1 do + if o.filters_and_sequence[i + pos] then + get_osd_log_contents(o.filters_and_sequence[i + pos]) + if osd_log_contents ~= nil and osd_log_contents[1] then + target_pos = i + pos + break + end + end + end + end + + if o.loop_through_filters then + if not target_pos and pos > -1 or target_pos and target_pos > #o.filters_and_sequence then + for i = 1, #o.filters_and_sequence do + get_osd_log_contents(o.filters_and_sequence[i]) + if osd_log_contents ~= nil and osd_log_contents[1] then + target_pos = i + break + end + end + end + if not target_pos and pos < 0 or target_pos and target_pos < 1 then + for i = #o.filters_and_sequence, 1, -1 do + get_osd_log_contents(o.filters_and_sequence[i]) + if osd_log_contents ~= nil and osd_log_contents[1] then + target_pos = i + break + end + end + end + end + + if o.filters_and_sequence[target_pos] then + display_list(o.filters_and_sequence[target_pos], nil, 'hide-osd') + end +end + +function list_filter_next() + select_filter_sequence(1) +end +function list_filter_previous() + select_filter_sequence(-1) +end +--End of LogManager Filter Functions-- + +--LogManager (List Bind and Unbind)-- +function get_list_keybinds() + bind_keys(o.list_ignored_keybind, 'ignore') + bind_keys(o.list_move_up_keybind, 'move-up', list_move_up, 'repeatable') + bind_keys(o.list_move_down_keybind, 'move-down', list_move_down, 'repeatable') + bind_keys(o.list_move_first_keybind, 'move-first', list_move_first, 'repeatable') + bind_keys(o.list_move_last_keybind, 'move-last', list_move_last, 'repeatable') + bind_keys(o.list_page_up_keybind, 'page-up', list_page_up, 'repeatable') + bind_keys(o.list_page_down_keybind, 'page-down', list_page_down, 'repeatable') + bind_keys(o.list_select_keybind, 'list-select', list_select) + bind_keys(o.list_add_playlist_keybind, 'list-add-playlist', list_add_playlist) + bind_keys(o.list_add_playlist_highlighted_keybind, 'list-add-playlist-highlight', function()list_add_playlist('highlight')end) + bind_keys(o.list_delete_keybind, 'list-delete', list_delete) + bind_keys(o.list_delete_highlighted_keybind, 'list-delete-highlight', function()list_delete('highlight')end) + bind_keys(o.next_filter_sequence_keybind, 'list-filter-next', list_filter_next) + bind_keys(o.previous_filter_sequence_keybind, 'list-filter-previous', list_filter_previous) + bind_keys(o.list_search_activate_keybind, 'list-search-activate', list_search_activate) + bind_keys(o.list_highlight_all_keybind, 'list-highlight-all', list_highlight_all) + bind_keys(o.list_unhighlight_all_keybind, 'list-unhighlight-all', list_unhighlight_all) + bind_keys(o.list_cycle_sort_keybind, 'list-cycle-sort', list_cycle_sort) + bind_keys(o.keybinds_remove_keybind, 'keybind-slot-remove', slot_remove) + bind_keys(o.keybinds_remove_highlighted_keybind, 'keybind-slot-remove-highlight', function()slot_remove('highlight')end) + bind_keys(o.list_group_add_cycle_keybind, 'group-add-cycle', list_group_add_cycle) + bind_keys(o.list_group_add_cycle_highlighted_keybind, 'group-add-cycle-highlight', function()list_group_add_cycle('highlight')end) + bind_keys(o.list_groups_remove_keybind, 'group-remove', group_remove) + bind_keys(o.list_groups_remove_highlighted_keybind, 'group-remove-highlight', function()group_remove('highlight')end) + + for i = 1, #o.groups_list_and_keybind do + if not o.groups_list_and_keybind[i][2] then break end + mp.add_forced_key_binding(o.groups_list_and_keybind[i][2], 'group-add-'..i, function()group_add(i)end) + end + for i = 1, #o.groups_list_and_keybind do + if not o.groups_list_and_keybind[i][3] then break end + mp.add_forced_key_binding(o.groups_list_and_keybind[i][3], 'group-add-highlight-'..i, function()group_add(i, 'highlight')end) + end + + for i = 1, #o.list_highlight_move_keybind do + for j = 1, #o.list_move_up_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_move_up_keybind[j], 'highlight-move-up'..j, function()list_move_up('highlight') end, 'repeatable') + end + for j = 1, #o.list_move_down_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_move_down_keybind[j], 'highlight-move-down'..j, function()list_move_down('highlight') end, 'repeatable') + end + for j = 1, #o.list_move_first_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_move_first_keybind[j], 'highlight-move-first'..j, function()list_move_first('highlight') end, 'repeatable') + end + for j = 1, #o.list_move_last_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_move_last_keybind[j], 'highlight-move-last'..j, function()list_move_last('highlight') end, 'repeatable') + end + for j = 1, #o.list_page_up_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_page_up_keybind[j], 'highlight-page-up'..j, function()list_page_up('highlight') end, 'repeatable') + end + for j = 1, #o.list_page_down_keybind do + mp.add_forced_key_binding(o.list_highlight_move_keybind[i]..'+'..o.list_page_down_keybind[j], 'highlight-page-down'..j, function()list_page_down('highlight') end, 'repeatable') + end + end + + if not search_active then + bind_keys(o.list_close_keybind, 'list-close', list_close_and_trash_collection) + end + + for i = 1, #o.list_filter_jump_keybind do + mp.add_forced_key_binding(o.list_filter_jump_keybind[i][1], 'list-filter-jump'..i, function()display_list(o.list_filter_jump_keybind[i][2]) end) + end + + for i = 1, #o.open_list_keybind do + if i == 1 then + mp.remove_key_binding('open-list') + else + mp.remove_key_binding('open-list'..i) + end + end + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 then + mp.add_forced_key_binding("1", "recent-1", function()load(list_start + 1) end) + mp.add_forced_key_binding("2", "recent-2", function()load(list_start + 2) end) + mp.add_forced_key_binding("3", "recent-3", function()load(list_start + 3) end) + mp.add_forced_key_binding("4", "recent-4", function()load(list_start + 4) end) + mp.add_forced_key_binding("5", "recent-5", function()load(list_start + 5) end) + mp.add_forced_key_binding("6", "recent-6", function()load(list_start + 6) end) + mp.add_forced_key_binding("7", "recent-7", function()load(list_start + 7) end) + mp.add_forced_key_binding("8", "recent-8", function()load(list_start + 8) end) + mp.add_forced_key_binding("9", "recent-9", function()load(list_start + 9) end) + mp.add_forced_key_binding("0", "recent-0", function()load(list_start + 10) end) + end +end + +function unbind_list_keys() + unbind_keys(o.list_ignored_keybind, 'ignore') + unbind_keys(o.list_move_up_keybind, 'move-up') + unbind_keys(o.list_move_down_keybind, 'move-down') + unbind_keys(o.list_move_first_keybind, 'move-first') + unbind_keys(o.list_move_last_keybind, 'move-last') + unbind_keys(o.list_page_up_keybind, 'page-up') + unbind_keys(o.list_page_down_keybind, 'page-down') + unbind_keys(o.list_select_keybind, 'list-select') + unbind_keys(o.list_add_playlist_keybind, 'list-add-playlist') + unbind_keys(o.list_add_playlist_highlighted_keybind, 'list-add-playlist-highlight') + unbind_keys(o.list_delete_keybind, 'list-delete') + unbind_keys(o.list_delete_highlighted_keybind, 'list-delete-highlight') + unbind_keys(o.list_close_keybind, 'list-close') + unbind_keys(o.next_filter_sequence_keybind, 'list-filter-next') + unbind_keys(o.previous_filter_sequence_keybind, 'list-filter-previous') + unbind_keys(o.list_highlight_all_keybind, 'list-highlight-all') + unbind_keys(o.list_highlight_all_keybind, 'list-unhighlight-all') + unbind_keys(o.list_cycle_sort_keybind, 'list-cycle-sort') + unbind_keys(o.keybinds_remove_keybind, 'keybind-slot-remove') + unbind_keys(o.keybinds_remove_keybind, 'keybind-slot-remove-highlight') + + unbind_keys(o.list_group_add_cycle_keybind, 'group-add-cycle') + unbind_keys(o.list_group_add_cycle_highlighted_keybind, 'group-add-cycle-highlight') + unbind_keys(o.list_groups_remove_keybind, 'group-remove') + unbind_keys(o.list_groups_remove_highlighted_keybind, 'group-remove-highlight') + + for i = 1, #o.groups_list_and_keybind do + if not o.groups_list_and_keybind[i][2] then break end + mp.remove_key_binding('group-add-'..i) + end + for i = 1, #o.groups_list_and_keybind do + if not o.groups_list_and_keybind[i][3] then break end + mp.remove_key_binding('group-add-highlight-'..i) + end + + for i = 1, #o.list_move_up_keybind do + mp.remove_key_binding('highlight-move-up'..i) + end + for i = 1, #o.list_move_down_keybind do + mp.remove_key_binding('highlight-move-down'..i) + end + for i = 1, #o.list_move_first_keybind do + mp.remove_key_binding('highlight-move-first'..i) + end + for i = 1, #o.list_move_last_keybind do + mp.remove_key_binding('highlight-move-last'..i) + end + for i = 1, #o.list_page_up_keybind do + mp.remove_key_binding('highlight-page-up'..i) + end + for i = 1, #o.list_page_down_keybind do + mp.remove_key_binding('highlight-page-down'..i) + end + + for i = 1, #o.list_filter_jump_keybind do + mp.remove_key_binding('list-filter-jump'..i) + end + + for i = 1, #o.open_list_keybind do + if i == 1 then + mp.add_forced_key_binding(o.open_list_keybind[i][1], 'open-list', function()display_list(o.open_list_keybind[i][2]) end) + else + mp.add_forced_key_binding(o.open_list_keybind[i][1], 'open-list'..i, function()display_list(o.open_list_keybind[i][2]) end) + end + end + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 then + mp.remove_key_binding("recent-1") + mp.remove_key_binding("recent-2") + mp.remove_key_binding("recent-3") + mp.remove_key_binding("recent-4") + mp.remove_key_binding("recent-5") + mp.remove_key_binding("recent-6") + mp.remove_key_binding("recent-7") + mp.remove_key_binding("recent-8") + mp.remove_key_binding("recent-9") + mp.remove_key_binding("recent-0") + end +end + +function list_close_and_trash_collection() + if utils.shared_script_property_set then + utils.shared_script_property_set('simplebookmark-menu-open', 'no') + end + mp.set_property('user-data/simplebookmark/menu-open', 'no') + if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'yes', 'no_osd') end + unbind_list_keys() + unbind_search_keys() + mp.set_osd_ass(0, 0, "") + list_drawn = false + list_cursor = 1 + list_start = 0 + filterName = 'all' + list_pages = {} + search_string = '' + search_active = false + list_highlight_cursor = {} + sortName = nil +end +--End of LogManager (List Bind and Unbind)-- + +--LogManager Search Feature-- +function list_search_exit() + search_active = false + get_osd_log_contents(filterName) + get_page_properties(filterName) + select(0) + unbind_search_keys() + get_list_keybinds() +end + +function list_search_not_typing_mode(auto_triggered) + if auto_triggered then + if search_string ~= '' and osd_log_contents[1] then + search_active = 'not_typing' + elseif not osd_log_contents[1] then + return + else + search_active = false + end + else + if search_string ~= '' then + search_active = 'not_typing' + else + search_active = false + end + end + select(0) + unbind_search_keys() + get_list_keybinds() +end + +function list_search_activate() + if not list_drawn then return end + if search_active == 'typing' then list_search_exit() return end + search_active = 'typing' + + for i = 1, #list_pages do + if list_pages[i][1] == filterName then + list_pages[i][2] = list_cursor + list_pages[i][4] = list_highlight_cursor + list_pages[i][5] = sortName + end + end + + update_search_results('','') + bind_search_keys() +end + +function update_search_results(character, action) + if not character then character = '' end + if action == 'string_del' then + search_string = search_string:sub(1, -2) + end + search_string = search_string..character + local prev_contents_length = #osd_log_contents + get_osd_log_contents(filterName) + + if prev_contents_length ~= #osd_log_contents then + list_highlight_cursor = {} + end + + if character ~= '' and #osd_log_contents > 0 or action ~= nil and #osd_log_contents > 0 then + select(1-list_cursor) + elseif #osd_log_contents == 0 then + list_cursor = 0 + select(list_cursor) + else + select(0) + end +end + +function bind_search_keys() + mp.add_forced_key_binding('a', 'search_string_a', function() update_search_results('a') end, 'repeatable') + mp.add_forced_key_binding('b', 'search_string_b', function() update_search_results('b') end, 'repeatable') + mp.add_forced_key_binding('c', 'search_string_c', function() update_search_results('c') end, 'repeatable') + mp.add_forced_key_binding('d', 'search_string_d', function() update_search_results('d') end, 'repeatable') + mp.add_forced_key_binding('e', 'search_string_e', function() update_search_results('e') end, 'repeatable') + mp.add_forced_key_binding('f', 'search_string_f', function() update_search_results('f') end, 'repeatable') + mp.add_forced_key_binding('g', 'search_string_g', function() update_search_results('g') end, 'repeatable') + mp.add_forced_key_binding('h', 'search_string_h', function() update_search_results('h') end, 'repeatable') + mp.add_forced_key_binding('i', 'search_string_i', function() update_search_results('i') end, 'repeatable') + mp.add_forced_key_binding('j', 'search_string_j', function() update_search_results('j') end, 'repeatable') + mp.add_forced_key_binding('k', 'search_string_k', function() update_search_results('k') end, 'repeatable') + mp.add_forced_key_binding('l', 'search_string_l', function() update_search_results('l') end, 'repeatable') + mp.add_forced_key_binding('m', 'search_string_m', function() update_search_results('m') end, 'repeatable') + mp.add_forced_key_binding('n', 'search_string_n', function() update_search_results('n') end, 'repeatable') + mp.add_forced_key_binding('o', 'search_string_o', function() update_search_results('o') end, 'repeatable') + mp.add_forced_key_binding('p', 'search_string_p', function() update_search_results('p') end, 'repeatable') + mp.add_forced_key_binding('q', 'search_string_q', function() update_search_results('q') end, 'repeatable') + mp.add_forced_key_binding('r', 'search_string_r', function() update_search_results('r') end, 'repeatable') + mp.add_forced_key_binding('s', 'search_string_s', function() update_search_results('s') end, 'repeatable') + mp.add_forced_key_binding('t', 'search_string_t', function() update_search_results('t') end, 'repeatable') + mp.add_forced_key_binding('u', 'search_string_u', function() update_search_results('u') end, 'repeatable') + mp.add_forced_key_binding('v', 'search_string_v', function() update_search_results('v') end, 'repeatable') + mp.add_forced_key_binding('w', 'search_string_w', function() update_search_results('w') end, 'repeatable') + mp.add_forced_key_binding('x', 'search_string_x', function() update_search_results('x') end, 'repeatable') + mp.add_forced_key_binding('y', 'search_string_y', function() update_search_results('y') end, 'repeatable') + mp.add_forced_key_binding('z', 'search_string_z', function() update_search_results('z') end, 'repeatable') + + mp.add_forced_key_binding('A', 'search_string_A', function() update_search_results('A') end, 'repeatable') + mp.add_forced_key_binding('B', 'search_string_B', function() update_search_results('B') end, 'repeatable') + mp.add_forced_key_binding('C', 'search_string_C', function() update_search_results('C') end, 'repeatable') + mp.add_forced_key_binding('D', 'search_string_D', function() update_search_results('D') end, 'repeatable') + mp.add_forced_key_binding('E', 'search_string_E', function() update_search_results('E') end, 'repeatable') + mp.add_forced_key_binding('F', 'search_string_F', function() update_search_results('F') end, 'repeatable') + mp.add_forced_key_binding('G', 'search_string_G', function() update_search_results('G') end, 'repeatable') + mp.add_forced_key_binding('H', 'search_string_H', function() update_search_results('H') end, 'repeatable') + mp.add_forced_key_binding('I', 'search_string_I', function() update_search_results('I') end, 'repeatable') + mp.add_forced_key_binding('J', 'search_string_J', function() update_search_results('J') end, 'repeatable') + mp.add_forced_key_binding('K', 'search_string_K', function() update_search_results('K') end, 'repeatable') + mp.add_forced_key_binding('L', 'search_string_L', function() update_search_results('L') end, 'repeatable') + mp.add_forced_key_binding('M', 'search_string_M', function() update_search_results('M') end, 'repeatable') + mp.add_forced_key_binding('N', 'search_string_N', function() update_search_results('N') end, 'repeatable') + mp.add_forced_key_binding('O', 'search_string_O', function() update_search_results('O') end, 'repeatable') + mp.add_forced_key_binding('P', 'search_string_P', function() update_search_results('P') end, 'repeatable') + mp.add_forced_key_binding('Q', 'search_string_Q', function() update_search_results('Q') end, 'repeatable') + mp.add_forced_key_binding('R', 'search_string_R', function() update_search_results('R') end, 'repeatable') + mp.add_forced_key_binding('S', 'search_string_S', function() update_search_results('S') end, 'repeatable') + mp.add_forced_key_binding('T', 'search_string_T', function() update_search_results('T') end, 'repeatable') + mp.add_forced_key_binding('U', 'search_string_U', function() update_search_results('U') end, 'repeatable') + mp.add_forced_key_binding('V', 'search_string_V', function() update_search_results('V') end, 'repeatable') + mp.add_forced_key_binding('W', 'search_string_W', function() update_search_results('W') end, 'repeatable') + mp.add_forced_key_binding('X', 'search_string_X', function() update_search_results('X') end, 'repeatable') + mp.add_forced_key_binding('Y', 'search_string_Y', function() update_search_results('Y') end, 'repeatable') + mp.add_forced_key_binding('Z', 'search_string_Z', function() update_search_results('Z') end, 'repeatable') + + mp.add_forced_key_binding('1', 'search_string_1', function() update_search_results('1') end, 'repeatable') + mp.add_forced_key_binding('2', 'search_string_2', function() update_search_results('2') end, 'repeatable') + mp.add_forced_key_binding('3', 'search_string_3', function() update_search_results('3') end, 'repeatable') + mp.add_forced_key_binding('4', 'search_string_4', function() update_search_results('4') end, 'repeatable') + mp.add_forced_key_binding('5', 'search_string_5', function() update_search_results('5') end, 'repeatable') + mp.add_forced_key_binding('6', 'search_string_6', function() update_search_results('6') end, 'repeatable') + mp.add_forced_key_binding('7', 'search_string_7', function() update_search_results('7') end, 'repeatable') + mp.add_forced_key_binding('8', 'search_string_8', function() update_search_results('8') end, 'repeatable') + mp.add_forced_key_binding('9', 'search_string_9', function() update_search_results('9') end, 'repeatable') + mp.add_forced_key_binding('0', 'search_string_0', function() update_search_results('0') end, 'repeatable') + + mp.add_forced_key_binding('SPACE', 'search_string_space', function() update_search_results(' ') end, 'repeatable') + mp.add_forced_key_binding('`', 'search_string_`', function() update_search_results('`') end, 'repeatable') + mp.add_forced_key_binding('~', 'search_string_~', function() update_search_results('~') end, 'repeatable') + mp.add_forced_key_binding('!', 'search_string_!', function() update_search_results('!') end, 'repeatable') + mp.add_forced_key_binding('@', 'search_string_@', function() update_search_results('@') end, 'repeatable') + mp.add_forced_key_binding('SHARP', 'search_string_sharp', function() update_search_results('#') end, 'repeatable') + mp.add_forced_key_binding('$', 'search_string_$', function() update_search_results('$') end, 'repeatable') + mp.add_forced_key_binding('%', 'search_string_percentage', function() update_search_results('%') end, 'repeatable') + mp.add_forced_key_binding('^', 'search_string_^', function() update_search_results('^') end, 'repeatable') + mp.add_forced_key_binding('&', 'search_string_&', function() update_search_results('&') end, 'repeatable') + mp.add_forced_key_binding('*', 'search_string_*', function() update_search_results('*') end, 'repeatable') + mp.add_forced_key_binding('(', 'search_string_(', function() update_search_results('(') end, 'repeatable') + mp.add_forced_key_binding(')', 'search_string_)', function() update_search_results(')') end, 'repeatable') + mp.add_forced_key_binding('-', 'search_string_-', function() update_search_results('-') end, 'repeatable') + mp.add_forced_key_binding('_', 'search_string__', function() update_search_results('_') end, 'repeatable') + mp.add_forced_key_binding('=', 'search_string_=', function() update_search_results('=') end, 'repeatable') + mp.add_forced_key_binding('+', 'search_string_+', function() update_search_results('+') end, 'repeatable') + mp.add_forced_key_binding('\\', 'search_string_\\', function() update_search_results('\\') end, 'repeatable') + mp.add_forced_key_binding('|', 'search_string_|', function() update_search_results('|') end, 'repeatable') + mp.add_forced_key_binding(']', 'search_string_]', function() update_search_results(']') end, 'repeatable') + mp.add_forced_key_binding('}', 'search_string_rightcurly', function() update_search_results('}') end, 'repeatable') + mp.add_forced_key_binding('[', 'search_string_[', function() update_search_results('[') end, 'repeatable') + mp.add_forced_key_binding('{', 'search_string_leftcurly', function() update_search_results('{') end, 'repeatable') + mp.add_forced_key_binding('\'', 'search_string_\'', function() update_search_results('\'') end, 'repeatable') + mp.add_forced_key_binding('\"', 'search_string_\"', function() update_search_results('\"') end, 'repeatable') + mp.add_forced_key_binding(';', 'search_string_semicolon', function() update_search_results(';') end, 'repeatable') + mp.add_forced_key_binding(':', 'search_string_:', function() update_search_results(':') end, 'repeatable') + mp.add_forced_key_binding('/', 'search_string_/', function() update_search_results('/') end, 'repeatable') + mp.add_forced_key_binding('?', 'search_string_?', function() update_search_results('?') end, 'repeatable') + mp.add_forced_key_binding('.', 'search_string_.', function() update_search_results('.') end, 'repeatable') + mp.add_forced_key_binding('>', 'search_string_>', function() update_search_results('>') end, 'repeatable') + mp.add_forced_key_binding(',', 'search_string_,', function() update_search_results(',') end, 'repeatable') + mp.add_forced_key_binding('<', 'search_string_<', function() update_search_results('<') end, 'repeatable') + + mp.add_forced_key_binding('bs', 'search_string_del', function() update_search_results('', 'string_del') end, 'repeatable') + bind_keys(o.list_close_keybind, 'search_exit', function() list_search_exit() end) + bind_keys(o.list_search_not_typing_mode_keybind, 'search_string_not_typing', function()list_search_not_typing_mode(false) end) + + if o.search_not_typing_smartly then + bind_keys(o.next_filter_sequence_keybind, 'list-filter-next', function() list_filter_next() list_search_not_typing_mode(true) end) + bind_keys(o.previous_filter_sequence_keybind, 'list-filter-previous', function() list_filter_previous() list_search_not_typing_mode(true) end) + bind_keys(o.list_delete_keybind, 'list-delete', function() list_delete() list_search_not_typing_mode(true) end) + bind_keys(o.list_delete_highlighted_keybind, 'list-delete-highlight', function() list_delete('highlight') list_search_not_typing_mode(true) end) + bind_keys(o.keybinds_remove_keybind, 'keybind-slot-remove', function() slot_remove() list_search_not_typing_mode(true) end) + bind_keys(o.keybinds_remove_keybind, 'keybind-slot-remove-highlight', function() slot_remove('highlight') list_search_not_typing_mode(true) end) + end +end + +function unbind_search_keys() + mp.remove_key_binding('search_string_a') + mp.remove_key_binding('search_string_b') + mp.remove_key_binding('search_string_c') + mp.remove_key_binding('search_string_d') + mp.remove_key_binding('search_string_e') + mp.remove_key_binding('search_string_f') + mp.remove_key_binding('search_string_g') + mp.remove_key_binding('search_string_h') + mp.remove_key_binding('search_string_i') + mp.remove_key_binding('search_string_j') + mp.remove_key_binding('search_string_k') + mp.remove_key_binding('search_string_l') + mp.remove_key_binding('search_string_m') + mp.remove_key_binding('search_string_n') + mp.remove_key_binding('search_string_o') + mp.remove_key_binding('search_string_p') + mp.remove_key_binding('search_string_q') + mp.remove_key_binding('search_string_r') + mp.remove_key_binding('search_string_s') + mp.remove_key_binding('search_string_t') + mp.remove_key_binding('search_string_u') + mp.remove_key_binding('search_string_v') + mp.remove_key_binding('search_string_w') + mp.remove_key_binding('search_string_x') + mp.remove_key_binding('search_string_y') + mp.remove_key_binding('search_string_z') + + mp.remove_key_binding('search_string_A') + mp.remove_key_binding('search_string_B') + mp.remove_key_binding('search_string_C') + mp.remove_key_binding('search_string_D') + mp.remove_key_binding('search_string_E') + mp.remove_key_binding('search_string_F') + mp.remove_key_binding('search_string_G') + mp.remove_key_binding('search_string_H') + mp.remove_key_binding('search_string_I') + mp.remove_key_binding('search_string_J') + mp.remove_key_binding('search_string_K') + mp.remove_key_binding('search_string_L') + mp.remove_key_binding('search_string_M') + mp.remove_key_binding('search_string_N') + mp.remove_key_binding('search_string_O') + mp.remove_key_binding('search_string_P') + mp.remove_key_binding('search_string_Q') + mp.remove_key_binding('search_string_R') + mp.remove_key_binding('search_string_S') + mp.remove_key_binding('search_string_T') + mp.remove_key_binding('search_string_U') + mp.remove_key_binding('search_string_V') + mp.remove_key_binding('search_string_W') + mp.remove_key_binding('search_string_X') + mp.remove_key_binding('search_string_Y') + mp.remove_key_binding('search_string_Z') + + mp.remove_key_binding('search_string_1') + mp.remove_key_binding('search_string_2') + mp.remove_key_binding('search_string_3') + mp.remove_key_binding('search_string_4') + mp.remove_key_binding('search_string_5') + mp.remove_key_binding('search_string_6') + mp.remove_key_binding('search_string_7') + mp.remove_key_binding('search_string_8') + mp.remove_key_binding('search_string_9') + mp.remove_key_binding('search_string_0') + + mp.remove_key_binding('search_string_space') + mp.remove_key_binding('search_string_`') + mp.remove_key_binding('search_string_~') + mp.remove_key_binding('search_string_!') + mp.remove_key_binding('search_string_@') + mp.remove_key_binding('search_string_sharp') + mp.remove_key_binding('search_string_$') + mp.remove_key_binding('search_string_percentage') + mp.remove_key_binding('search_string_^') + mp.remove_key_binding('search_string_&') + mp.remove_key_binding('search_string_*') + mp.remove_key_binding('search_string_(') + mp.remove_key_binding('search_string_)') + mp.remove_key_binding('search_string_-') + mp.remove_key_binding('search_string__') + mp.remove_key_binding('search_string_=') + mp.remove_key_binding('search_string_+') + mp.remove_key_binding('search_string_\\') + mp.remove_key_binding('search_string_|') + mp.remove_key_binding('search_string_]') + mp.remove_key_binding('search_string_rightcurly') + mp.remove_key_binding('search_string_[') + mp.remove_key_binding('search_string_leftcurly') + mp.remove_key_binding('search_string_\'') + mp.remove_key_binding('search_string_\"') + mp.remove_key_binding('search_string_semicolon') + mp.remove_key_binding('search_string_:') + mp.remove_key_binding('search_string_/') + mp.remove_key_binding('search_string_?') + mp.remove_key_binding('search_string_.') + mp.remove_key_binding('search_string_>') + mp.remove_key_binding('search_string_,') + mp.remove_key_binding('search_string_<') + + mp.remove_key_binding('search_string_del') + if not search_active then + unbind_keys(o.list_close_keybind, 'search_exit') + end +end +--End of LogManager Search Feature-- +---------End of LogManager--------- + +--Modify Additional Log Parameters-- +function remove_all_additional_param_log_entry(index, log_text) + if not index or not log_text then return end + local temp_log_contents = read_log_table() + if not temp_log_contents or not temp_log_contents[1] then return end + + for i = #temp_log_contents, 1, -1 do + if temp_log_contents[i].found_line:find(log_text..index) then + temp_log_contents[i].found_line = string.gsub(temp_log_contents[i].found_line, ' | '..log_text..index, "") + end + end + + f = io.open(log_fullpath, "w+") + if temp_log_contents ~= nil and temp_log_contents[1] then + for i = 1, #temp_log_contents do + f:write(("%s\n"):format(temp_log_contents[i].found_line)) + end + end + f:close() +end + +function remove_additional_param_log_entry(index, target, log_text) + if not index or not target or not log_text then return msg.error('remove_additional_param_log_entry parameters not defined') end + if not osd_log_contents or not osd_log_contents[1] then return end + local temp_log_contents = read_log_table() + if not temp_log_contents or not temp_log_contents[1] then return end + + local log_index = osd_log_contents[target].found_sequence + + if temp_log_contents[log_index].found_line:find(log_text..index) then + temp_log_contents[log_index].found_line = string.gsub(temp_log_contents[log_index].found_line, ' | '..log_text..index, "") + else + return msg.error('temp_log_contents[log_index].found_line is not found') + end + + f = io.open(log_fullpath, "w+") + if temp_log_contents ~= nil and temp_log_contents[1] then + for i = 1, #temp_log_contents do + f:write(("%s\n"):format(temp_log_contents[i].found_line)) + end + end + f:close() +end + +function add_additional_param_log_entry(index, target, log_text) + if not index or not target or not log_text then return msg.error('add_additional_param_log_entry parameters not defined') end + if not osd_log_contents or not osd_log_contents[1] then return end + local temp_log_contents = read_log_table() + if not temp_log_contents or not temp_log_contents[1] then return end + local log_index = osd_log_contents[target].found_sequence + + if temp_log_contents[log_index].found_line then + if temp_log_contents[log_index].found_line:sub(-1) ~= ' ' then + temp_log_contents[log_index].found_line = temp_log_contents[log_index].found_line..' | '..log_text .. index..' | ' + else + temp_log_contents[log_index].found_line = temp_log_contents[log_index].found_line..log_text .. index..' | ' + end + else + return msg.error('temp_log_contents[log_index].found_line is not found') + end + + f = io.open(log_fullpath, "w+") + if temp_log_contents ~= nil and temp_log_contents[1] then + for i = 1, #temp_log_contents do + f:write(("%s\n"):format(temp_log_contents[i].found_line)) + end + end + f:close() +end +--End Of Modify Additional Log Parameters-- + +--Keybind Slot Feature-- +function list_slot_remove(index, action) + if not list_drawn then return end + if not osd_log_contents or not osd_log_contents[1] then return end + if not index then index = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_slot) end + + if not index then + if action ~= 'silent' then msg.info("Failed to remove") end + return + end + remove_all_additional_param_log_entry(index, log_keybind_text) + if action ~= 'silent' then msg.info('Removed Keybind: ' .. get_slot_keybind(index)) end +end + +function list_slot_remove_highlighted() + if not list_drawn then return end + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + if not osd_log_contents or not osd_log_contents[1] then return end + + local slotIndex + for i = 1, #osd_log_contents do + for j=1, #list_highlight_cursor do + if osd_log_contents[#osd_log_contents+1-i] then + if osd_log_contents[#osd_log_contents+1-i].found_sequence == list_highlight_cursor[j][2].found_sequence then + slotIndex = tonumber(osd_log_contents[#osd_log_contents+1-i].found_slot) + if slotIndex then + remove_all_additional_param_log_entry(slotIndex, log_keybind_text) + msg.info('Removed Keybind: ' .. get_slot_keybind(slotIndex)) + end + end + end + end + end +end + +function list_slot_add(index) + if not list_drawn then return end + if not osd_log_contents or not osd_log_contents[1] then return end + if not index then return end + + local cursor_filename, cursor_filepath, cursor_filetitle = get_local_names(osd_log_contents[#osd_log_contents - list_cursor + 1]) --1.2.4# added the new name calling method to fix issue of unable to add urls into groups or slots + local cursor_seektime = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_time) + if not cursor_filename or not cursor_seektime then + msg.info("Failed to add slot") + return + end + + + local slotIndex = osd_log_contents[#osd_log_contents - list_cursor + 1].found_slot + if slotIndex then + remove_additional_param_log_entry(slotIndex,#osd_log_contents-list_cursor+1, log_keybind_text) + end + + list_slot_remove(index, 'silent') + add_additional_param_log_entry(index, #osd_log_contents-list_cursor+1, log_keybind_text) + msg.info('Added Keybind:\n' .. cursor_filetitle .. ' 🕒 ' .. format_time(cursor_seektime) .. ' ⌨ ' .. get_slot_keybind(index)) +end + +function slot_remove(action) + if not action then + list_slot_remove() + elseif action == 'highlight' then + list_slot_remove_highlighted() + end + get_osd_log_contents() + if #osd_log_contents == 0 then + display_list('all') + return + elseif list_cursor ~= #osd_log_contents + 1 then + select(0) + else + select(-1) + end +end + +function slot_add(index) + if not index then return end + + list_slot_add(index) + get_osd_log_contents() + if #osd_log_contents == 0 then + list_cursor = 0 + select(list_cursor) + elseif list_cursor ~= #osd_log_contents + 1 then + select(0) + else + select(-1) + end +end +--End of Keybind Slot Feature-- + +--Group Feature-- +function list_group_remove(action) + if not list_drawn then return end + if not osd_log_contents or not osd_log_contents[1] then return end + + local groupCursorIndex = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_group) + if not groupCursorIndex then + if action ~= 'silent' then msg.info("Failed to remove") end + return + end + remove_additional_param_log_entry(groupCursorIndex, #osd_log_contents-list_cursor+1, log_group_text) + if action ~= 'silent' then msg.info('Removed Group: ' .. get_group_properties(groupCursorIndex).name) end +end + +function list_group_remove_highlighted() + if not list_drawn then return end + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + if not osd_log_contents or not osd_log_contents[1] then return end + + local groupIndex + for i = 1, #osd_log_contents do + for j=1, #list_highlight_cursor do + if osd_log_contents[#osd_log_contents+1-i] then + if osd_log_contents[#osd_log_contents+1-i].found_sequence == list_highlight_cursor[j][2].found_sequence then + groupIndex = tonumber(osd_log_contents[#osd_log_contents+1-i].found_group) + if groupIndex then + remove_additional_param_log_entry(groupIndex, #osd_log_contents+1-i, log_group_text) + msg.info('Removed Group: ' .. get_group_properties(groupIndex).name) + end + end + end + end + end +end + +function list_group_add(index) + if not list_drawn then return end + if not osd_log_contents or not osd_log_contents[1] then return end + if not index then return end + + local cursor_filename, cursor_filepath, cursor_filetitle = get_local_names(osd_log_contents[#osd_log_contents - list_cursor + 1]) --1.2.4# added the new name calling method to fix issue of unable to add urls into groups or slots + local cursor_seektime = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_time) + if not cursor_filename or not cursor_seektime then + msg.info("Failed to add group") + return + end + + list_group_remove('silent') + add_additional_param_log_entry(index, #osd_log_contents-list_cursor+1, log_group_text) + msg.info('Added Group:\n' .. cursor_filename .. ' 🕒 ' .. format_time(cursor_seektime) .. ' 🖿 ' .. get_group_properties(index).name) +end + +function list_group_add_highlighted(index) + if not list_drawn then return end + if not list_highlight_cursor or not list_highlight_cursor[1] then return end + if not osd_log_contents or not osd_log_contents[1] then return end + if not index then return end + list_group_remove_highlighted() + + for i = 1, #osd_log_contents do + for j=1, #list_highlight_cursor do + if osd_log_contents[#osd_log_contents+1-i] then + if osd_log_contents[#osd_log_contents+1-i].found_sequence == list_highlight_cursor[j][2].found_sequence then + add_additional_param_log_entry(index, #osd_log_contents+1-i, log_group_text) + msg.info('Added Group: ' .. get_group_properties(index).name) + end + end + end + end +end + +function list_group_add_cycle(action) + if not list_drawn then return end + if not osd_log_contents or not osd_log_contents[1] then return end + + local next_index = tonumber(osd_log_contents[#osd_log_contents - list_cursor + 1].found_group) + if next_index then next_index = next_index + 1 else next_index = 0 end + if next_index > #o.groups_list_and_keybind or next_index == 0 then + next_index = 1 + end + + if not action then + group_add(next_index) + elseif action == 'highlight' then + group_add(next_index, action) + end +end + +function group_remove(action) + if not action then + list_group_remove() + elseif action == 'highlight' then + list_group_remove_highlighted() + end + get_osd_log_contents() + if #osd_log_contents == 0 then + display_list('all') + return + elseif list_cursor ~= #osd_log_contents + 1 then + select(0) + else + select(-1) + end +end + +function group_add(index, action) + if not index then return end + + if not action then + list_group_add(index) + elseif action == 'highlight' then + list_group_add_highlighted(index) + end + get_osd_log_contents() + if #osd_log_contents == 0 then + list_cursor = 0 + select(list_cursor) + elseif list_cursor ~= #osd_log_contents + 1 then + select(0) + else + select(-1) + end +end +--End of Group Feature-- + +function mark_chapter() + if not o.mark_bookmark_as_chapter then return end + + local all_chapters = mp.get_property_native("chapter-list") + local chapter_index = 0 + local chapters_time = {} + + get_osd_log_contents() + if not osd_log_contents or not osd_log_contents[1] then return end + for i = 1, #osd_log_contents do + if osd_log_contents[i].found_path == filePath and tonumber(osd_log_contents[i].found_time) > 0 then + table.insert(chapters_time, tonumber(osd_log_contents[i].found_time)) + end + end + if not chapters_time[1] then return end + + table.sort(chapters_time, function(a, b) return a < b end) + + for i = 1, #chapters_time do + chapter_index = chapter_index + 1 + + all_chapters[chapter_index] = { + title = 'SimpleBookmark ' .. chapter_index, + time = chapters_time[i] + } + end + + table.sort(all_chapters, function(a, b) return a['time'] < b['time'] end) + + mp.set_property_native("chapter-list", all_chapters) +end + +function write_log(target_time, update_seekTime, entry_limit) + if not filePath then return end + if o.preserve_video_settings then mp.command("write-watch-later-config") end--1.3.1# option to preserve video settings by using write-watch-later-config when saving bookmark #84 + + local prev_seekTime = seekTime + local deleted_entries = {} --1.2.7# add it above since we need to call it later for preserving properties + + seekTime = (mp.get_property_number('time-pos') or 0) + if target_time then + seekTime = target_time + end + if seekTime < 0 then seekTime = 0 end + + local found_entry = find_entry(true, filePath, math.floor(seekTime)) --1.2.5# finds entry_sequence using new function --1.2.6# updated to find_entry + --1.2.8# first delete_log_entry to correctly overwrite the data (having same_path_log_delete() function runs first will result in overwriting wrong entry) + if found_entry and found_entry['found_sequence'] ~= nil then --1.2.6# if the entry exists then proceed to delete it + delete_log_entry(found_entry['found_sequence']) --1.2.5# deletes log entry using new function that uses sequence to delete --1.2.8# removed calling the array earlier and automatically call inside function + end + deleted_entries = same_path_log_delete(filePath, entry_limit) --1.2.5# seperate function to delete any additional entries based on the same_entry_limit set by user --1.2.7# assign it to varible since function now returns status and an array of deleted_entries --1.2.8# removed calling the array earlier and automatically call inside function + + local f = io.open(log_fullpath, "a+")--1.3# dont allow customization to date_format so it can be saved in a standard in which I can parse for search results, etc.. + if o.file_title_logging == 'all' then + f:write(("[%s] \"%s\" | %s | %s | %s | "):format(os.date("%Y-%m-%dT%H:%M:%S"), fileTitle, filePath, log_length_text .. tostring(fileLength), log_time_text .. tostring(seekTime))) + elseif o.file_title_logging == 'protocols' and (starts_protocol(o.logging_protocols, filePath)) or o.file_title_logging == 'local' and not (starts_protocol(o.logging_protocols, filePath)) then --1.3# added file_title_logging for local + f:write(("[%s] \"%s\" | %s | %s | %s | "):format(os.date("%Y-%m-%dT%H:%M:%S"), fileTitle, filePath, log_length_text .. tostring(fileLength), log_time_text .. tostring(seekTime))) + elseif o.file_title_logging == 'protocols' and not (starts_protocol(o.logging_protocols, filePath)) then + f:write(("[%s] %s | %s | %s | "):format(os.date("%Y-%m-%dT%H:%M:%S"), filePath, log_length_text .. tostring(fileLength), log_time_text .. tostring(seekTime))) + else + f:write(("[%s] %s | %s | %s | "):format(os.date("%Y-%m-%dT%H:%M:%S"), filePath, log_length_text .. tostring(fileLength), log_time_text .. tostring(seekTime))) + end + + f:write('\n') + f:close() + + + --1.2.6# restore properties if o.overwrite_preserve_properties is enabled + if found_entry and o.overwrite_preserve_properties then + local temp_log_contents = read_log_table() --1.2.6# loop through table with the new additions + if not temp_log_contents or not temp_log_contents[1] then return end + --1.2.6# when a slot or group was found previously, then add it + if found_entry['found_slot'] then + remove_all_additional_param_log_entry(found_entry['found_slot'], log_keybind_text) --1.2.9# replaced list_slot_remove with remove_all_.. function to avoid possible errors since list_slot_remove has a check for list_drawn + add_additional_param_log_entry(found_entry['found_slot'], #temp_log_contents, log_keybind_text) + end + if found_entry['found_group'] then + add_additional_param_log_entry(found_entry['found_group'], #temp_log_contents, log_group_text) + end + end + + --1.2.7# if an exact match is not found, and there are multiple deleted entries because of same_path_log_delete then add the latest deleted property to the newly added entry + if not found_entry and deleted_entries ~= nil and deleted_entries[1] and o.overwrite_preserve_properties then + local temp_log_contents = read_log_table() --1.2.6# loop through table with the new additions + if not temp_log_contents or not temp_log_contents[1] then return end + --1.2.7# loop through all deleted entries and get the first found slot and group then append it to the latest entry and then break loop + local break_table = false + for i = 1, #deleted_entries do + if deleted_entries[i] then + if deleted_entries[i].found_slot then + remove_all_additional_param_log_entry(deleted_entries[i].found_slot, log_keybind_text) --1.2.9# replaced list_slot_remove with remove_all_.. function to avoid possible errors since list_slot_remove has a check for list_drawn + add_additional_param_log_entry(deleted_entries[i].found_slot, #temp_log_contents, log_keybind_text) + break_table = true --1.2.7# break the table after addition is added since no need to continue looking for more + end + if deleted_entries[i].found_group then + add_additional_param_log_entry(deleted_entries[i].found_group, #temp_log_contents, log_group_text) + break_table = true --1.2.7# break the table after addition is added since no need to continue looking for more + end + if break_table then --1.2.7# if it found a slot and group or just slot or just a group then break the table + break + end + end + end + end + + if not update_seekTime then + seekTime = prev_seekTime + end +end + +function add_load_slot(key_index) + if not key_index then return end + + local current_filePath = mp.get_property('path') + local list_filepath, list_filetitle, list_seektime + if list_drawn then + slot_add(key_index) + else + local slot_taken = false + get_osd_log_contents() + if osd_log_contents ~= nil and osd_log_contents[1] then + for i = 1, #osd_log_contents do + if tonumber(osd_log_contents[i].found_slot) == key_index then + list_filepath = osd_log_contents[i].found_path + list_filetitle = osd_log_contents[i].found_name + list_seektime = tonumber(osd_log_contents[i].found_time) + slot_taken = true + break + end + end + if slot_taken then + if file_exists(list_filepath) or starts_protocol(protocols, list_filepath) then + if list_filepath ~= current_filePath then + if o.preserve_video_settings then mp.command("write-watch-later-config") end--1.3.1# option to preserve video settings by using write-watch-later-config when loading bookmark replaces current file #84 + mp.commandv('loadfile', list_filepath) + if o.keybinds_auto_resume then + resume_selected = true + end + elseif list_filepath == current_filePath and o.keybinds_auto_resume then + mp.commandv('seek', list_seektime, 'absolute', 'exact') + list_close_and_trash_collection() + elseif list_filepath == current_filePath and not o.keybinds_auto_resume then + mp.commandv('seek', 0, 'absolute', 'exact') + list_close_and_trash_collection() + end + if o.keybinds_auto_resume then + if o.osd_messages == true then + mp.osd_message('Loaded slot:' .. ' ⌨ ' .. get_slot_keybind(key_index) .. '\n' .. list_filetitle .. ' 🕒 ' .. format_time(list_seektime, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1])) + end + msg.info('Loaded slot:' .. ' ⌨ ' .. get_slot_keybind(key_index) .. '\n' .. list_filetitle .. ' 🕒 ' .. format_time(list_seektime)) + else + if o.osd_messages == true then + mp.osd_message('Loaded slot:' .. ' ⌨ ' .. get_slot_keybind(key_index) .. '\n' .. list_filetitle) + end + msg.info('Loaded slot:' .. ' ⌨ ' .. get_slot_keybind(key_index) .. '\n' .. list_filetitle) + end + else + if o.osd_messages == true then + mp.osd_message('File Doesn\'t Exist:\n' .. list_filepath) + end + msg.info('The file below doesn\'t seem to exist:\n' .. list_filepath) + return + end + else + if o.keybinds_empty_auto_create then + if filePath ~= nil then + if o.keybinds_empty_fileonly then + write_log(0) --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + get_osd_log_contents() --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code -- also used get_osd instead of local because add_additional_param uses osd_log inside its function perhaps this should be changed + local current_slot = tonumber(osd_log_contents[#osd_log_contents].found_slot) --1.2.9# gets the slot of the current item + remove_all_additional_param_log_entry(current_slot, log_keybind_text) --1.2.9# removes all the slots of the current item + remove_all_additional_param_log_entry(key_index, log_keybind_text) --1.2.9# removes all the slots that are going to be added based on passed index + add_additional_param_log_entry(key_index, #osd_log_contents, log_keybind_text) --1.2.9# adds the slot of the passed index + else + write_log(false) --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + get_osd_log_contents() --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + local current_slot = tonumber(osd_log_contents[#osd_log_contents].found_slot) --1.2.9# gets the slot of the current item + remove_all_additional_param_log_entry(current_slot, log_keybind_text) --1.2.9# removes all the slots of the current item + remove_all_additional_param_log_entry(key_index, log_keybind_text) --1.2.9# removes all the slots that are going to be added based on passed index + add_additional_param_log_entry(key_index, #osd_log_contents, log_keybind_text) --1.2.9# adds the slot of the passed index + end + if o.osd_messages == true then + mp.osd_message('Bookmarked & Added Keybind:\n' .. fileTitle .. ' 🕒 ' .. format_time(mp.get_property_number('time-pos'), o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) .. ' ⌨ ' .. get_slot_keybind(key_index)) + end + msg.info('Bookmarked the below & added keybind:\n' .. fileTitle .. ' 🕒 ' .. format_time(mp.get_property_number('time-pos')) .. ' ⌨ ' .. get_slot_keybind(key_index)) + else + if o.osd_messages == true then + mp.osd_message('Failed to Bookmark & Auto Create Keybind\nNo Video Found') + end + msg.info("Failed to bookmark & auto create keybind, no video found") + end + else + if o.osd_messages == true then + mp.osd_message('No Bookmark Slot For' .. ' ⌨ ' .. get_slot_keybind(key_index) .. ' Yet') + end + msg.info('No bookmark slot has been assigned for' .. ' ⌨ ' .. get_slot_keybind(key_index) .. ' keybind yet') + end + end + else + if o.osd_messages == true then + mp.osd_message('No Bookmark Slot For' .. ' ⌨ ' .. get_slot_keybind(key_index) .. ' Yet') + end + msg.info('No bookmark slot has been assigned for' .. ' ⌨ ' .. get_slot_keybind(key_index) .. ' keybind yet') + end + end +end + +function quicksave_slot(key_index) + if not key_index then return end + + if list_drawn then + slot_add(key_index) + else + if filePath ~= nil then + if o.keybinds_quicksave_fileonly then + write_log(0) --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code -- also used get_osd instead of local because add_additional_param uses osd_log inside its function perhaps this should be changed + get_osd_log_contents() --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + local current_slot = tonumber(osd_log_contents[#osd_log_contents].found_slot) --1.2.9# gets the slot of the current item + remove_all_additional_param_log_entry(current_slot, log_keybind_text) --1.2.9# removes all the slots of the current item + remove_all_additional_param_log_entry(key_index, log_keybind_text) --1.2.9# removes all the slots that are going to be added based on passed index + add_additional_param_log_entry(key_index, #osd_log_contents, log_keybind_text) --1.2.9# adds the slot of the passed index + + if o.osd_messages == true then + mp.osd_message('Bookmarked Fileonly & Added Keybind:\n' .. fileTitle .. ' ⌨ ' .. get_slot_keybind(key_index)) + end + msg.info('Bookmarked the below & added keybind:\n' .. fileTitle .. ' ⌨ ' .. get_slot_keybind(key_index)) + else + write_log(false, true) --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + get_osd_log_contents() --1.2.9# reflect removal of key_index in write_log function, fixes bug and cleaner code + local current_slot = tonumber(osd_log_contents[#osd_log_contents].found_slot) --1.2.9# gets the slot of the current item + remove_all_additional_param_log_entry(current_slot, log_keybind_text) --1.2.9# removes all the slots of the current item + remove_all_additional_param_log_entry(key_index, log_keybind_text) --1.2.9# removes all the slots that are going to be added based on passed index + add_additional_param_log_entry(key_index, #osd_log_contents, log_keybind_text) --1.2.9# adds the slot of the passed index + + if o.osd_messages == true then + mp.osd_message('Bookmarked & Added Keybind:\n' .. fileTitle .. ' 🕒 ' .. format_time(seekTime, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) .. ' ⌨ ' .. get_slot_keybind(key_index)) + end + msg.info('Bookmarked the below & added keybind:\n' .. fileTitle .. ' 🕒 ' .. format_time(seekTime) .. ' ⌨ ' .. get_slot_keybind(key_index)) + end + else + if o.osd_messages == true then + mp.osd_message('Failed to Bookmark & Auto Create Keybind\nNo Video Found') + end + msg.info("Failed to bookmark & auto create keybind, no video found") + end + end +end + +function bookmark_save() + if filePath ~= nil then + write_log(false, true, o.same_entry_limit) --1.2.9# reflect removal of key_index in write_log function + if list_drawn then + get_osd_log_contents() + select(0) + end + if o.osd_messages == true then + mp.osd_message('Bookmarked:\n' .. fileTitle .. ' 🕒 ' .. format_time(seekTime, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1])) + end + msg.info('Added the below to bookmarks\n' .. fileTitle .. ' 🕒 ' .. format_time(seekTime)) + elseif filePath == nil and o.bookmark_loads_last_idle then + osd_log_contents = read_log_table() + load(1) + else + if o.osd_messages == true then + mp.osd_message('Failed to Bookmark\nNo Video Found') + end + msg.info("Failed to bookmark, no video found") + end +end + +function bookmark_fileonly_save() + if filePath ~= nil then + write_log(0, false, o.same_entry_limit) --1.2.9# reflect removal of key_index in write_log function + if list_drawn then + get_osd_log_contents() + select(0) + end + if o.osd_messages == true then + mp.osd_message('Bookmarked File Only:\n' .. fileTitle) + end + msg.info('Added the below to bookmarks\n' .. fileTitle) + elseif filePath == nil and o.bookmark_fileonly_loads_last_idle then + osd_log_contents = read_log_table() + load(1, false, 0) + else + if o.osd_messages == true then + mp.osd_message('Failed to Bookmark\nNo Video Found') + end + msg.info("Failed to bookmark, no video found") + end +end + +mp.register_event('file-loaded', function() + list_close_and_trash_collection() + filePath, fileTitle, fileLength = get_file() + loadTriggered = true --1.1.5# for resume and resume-notime startup behavior (so that it only triggers if started as idle and only once) + if (resume_selected == true and seekTime ~= nil) then + mp.commandv('seek', seekTime, 'absolute', 'exact') + resume_selected = false + end + mark_chapter() +end) + +mp.observe_property("idle-active", "bool", function(_, v) + if v then --1.3.0# if idle is triggered + filePath, fileTitle, fileLength = nil --1.3.0# set it back to nil if idle is triggered for better trash collection. issue #69 + end + + if v and o.auto_run_list_idle ~= 'none' then + display_list(o.auto_run_list_idle, nil, 'hide-osd') + end + + if v and type(o.load_item_on_startup) == "number" and not loadTriggered then --1.3.0# option to immediately load an entry based on number + if o.load_item_on_startup == 0 then return end --1.3.0# if the entry loaded is 0 then exit this, this is automatically handled in load also but it is better to exit here since there will be a loop below this + + osd_log_contents = read_log_table() --1.3.0# get the item list to use load function + if not osd_log_contents or not osd_log_contents[1] then return end + + if o.load_item_on_startup == -1 then o.load_item_on_startup = #osd_log_contents end --1.3.0# specify -1 as last entry + load(o.load_item_on_startup) + end +end) + +bind_keys(o.bookmark_save_keybind, 'bookmark-save', bookmark_save) +bind_keys(o.bookmark_fileonly_keybind, 'bookmark-fileonly', bookmark_fileonly_save) + +for i = 1, #o.open_list_keybind do + if i == 1 then + mp.add_forced_key_binding(o.open_list_keybind[i][1], 'open-list', function()display_list(o.open_list_keybind[i][2]) end) + else + mp.add_forced_key_binding(o.open_list_keybind[i][1], 'open-list'..i, function()display_list(o.open_list_keybind[i][2]) end) + end +end + +for i = 1, #o.keybinds_add_load_keybind do + mp.add_forced_key_binding(o.keybinds_add_load_keybind[i], 'keybind-slot-' .. i, function()add_load_slot(i) end) +end + +for i = 1, #o.keybinds_quicksave_keybind do + mp.add_forced_key_binding(o.keybinds_quicksave_keybind[i], 'keybind-slot-save-' .. i, function()quicksave_slot(i) end) +end diff --git a/mac/.config/mpv/scripts/SmartCopyPaste_II.lua b/mac/.config/mpv/scripts/SmartCopyPaste_II.lua new file mode 100644 index 0000000..ca5bf58 --- /dev/null +++ b/mac/.config/mpv/scripts/SmartCopyPaste_II.lua @@ -0,0 +1,3797 @@ +-- Copyright (c) 2022, Eisa AlAwadhi +-- License: BSD 2-Clause License +-- Creator: Eisa AlAwadhi +-- Project: SmartCopyPaste_II +-- Version: 3.2.1 + +local o = { + ---------------------------USER CUSTOMIZATION SETTINGS--------------------------- + --These settings are for users to manually change some options. + --Changes are recommended to be made in the script-opts directory. + + -----Script Settings---- + device = "auto", --'auto' is for automatic device detection, or manually change to: 'windows' or 'mac' or 'linux' + linux_copy = "xclip -silent -selection clipboard -in", --copy command that will be used in Linux. OR write a different command + linux_paste = "xclip -selection clipboard -o", --paste command that will be used in Linux. OR write a different command + mac_copy = "pbcopy", --copy command that will be used in MAC. OR write a different command + mac_paste = "pbpaste", --paste command that will be used in MAC. OR write a different command + windows_copy = "powershell", --'powershell' is for using windows powershell to copy. OR write the copy command, e.g: ' clip' + windows_paste = "powershell", --'powershell' is for using windows powershell to paste. OR write the paste command + auto_run_list_idle = "none", --Auto run the list when opening mpv and there is no video / file loaded. 'none' for disabled. Or choose between: 'all', 'copy', 'paste', 'recents', 'distinct', 'protocols', 'fileonly', 'titleonly', 'timeonly', 'keywords'. + toggle_idlescreen = false, --hides OSC idle screen message when opening and closing menu (could cause unexpected behavior if multiple scripts are triggering osc-idlescreen off) + resume_offset = -0.65, --change to 0 so item resumes from the exact position, or decrease the value so that it gives you a little preview before loading the resume point + osd_messages = true, --true is for displaying osd messages when actions occur. Change to false will disable all osd messages generated from this script + mark_clipboard_as_chapter = false, --true is for marking the time as a chapter. false disables mark as chapter behavior. + copy_time_method = "all", --Option to copy time with video, 'none' for disabled, 'all' to copy time for all videos, 'protocols' for copying time only for protocols, 'specifics' to copy time only for websites defined below, 'local' to copy time for videos that are not protocols + log_paste_idle_behavior = "force-noresume", --Behavior of paste when nothing valid is copied, and no video is running. select between 'force', 'force-noresume' + log_paste_running_behavior = "timestamp>playlist", --Behavior of paste when nothing valid is copied, and a video is running. select between 'timestamp>playlist', 'timestamp>force', 'timestamp', 'playlist', 'force', 'force-noresume' + specific_time_attributes = [[ + [ ["twitter", "?t=", ""], ["twitch", "?t=", "s"], ["youtube", "&t=", "s"] ] + ]], --The time attributes which will be added when copying protocols of specific websites from this list. Additional attributes can be added following the same format. + protocols_time_attribute = "&t=", --The default text that will be copied before the seek time when copying a protocol video from mpv, specific_time_attributes takes priority + local_time_attribute = "&time=", --The text that will be copied before the seek time when copying a local video from mpv + pastable_time_attributes = [[ + [" | time="] + ]], --The time attributes that can be pasted for resume, specific_time_attributes, protocols_time_attribute, local_time_attribute are automatically added + copy_keybind = [[ + ["ctrl+y", "ctrl+Y", "meta+y", "meta+Y"] + ]], --Keybind that will be used to copy + running_paste_behavior = "playlist", --The priority of paste behavior when a video is running. select between 'playlist', 'timestamp', 'force'. + paste_keybind = [[ + ["ctrl+p", "ctrl+P", "meta+p", "meta+P"] + ]], --Keybind that will be used to paste + copy_specific_behavior = "path", --Copy behavior when using copy_specific_keybind. select between 'title', 'path', 'timestamp', 'path×tamp'. + copy_specific_keybind = [[ + ["ctrl+alt+y", "ctrl+alt+Y", "meta+alt+y", "meta+alt+Y"] + ]], --Keybind that will be used to copy based on the copy behavior specified + paste_specific_behavior = "playlist", --Paste behavior when using paste_specific_keybind. select between 'playlist', 'timestamp', 'force'. + paste_specific_keybind = [[ + ["ctrl+alt+p", "ctrl+alt+P", "meta+alt+p", "meta+alt+P"] + ]], --Keybind that will be used to paste based on the paste behavior specified + paste_protocols = [[ + ["https?://", "magnet:", "rtmp:", "file:"] + ]], --add above (after a comma) any protocol you want paste to work with; e.g: ,'ftp://'. Or set it as "" by deleting all defined protocols to make paste works with any protocol. + paste_extensions = [[ + ["ac3", "a52", "eac3", "mlp", "dts", "dts-hd", "dtshd", "true-hd", "thd", "truehd", "thd+ac3", "tta", "pcm", "wav", "aiff", "aif", "aifc", "amr", "awb", "au", "snd", "lpcm", "yuv", "y4m", "ape", "wv", "shn", "m2ts", "m2t", "mts", "mtv", "ts", "tsv", "tsa", "tts", "trp", "adts", "adt", "mpa", "m1a", "m2a", "mp1", "mp2", "mp3", "mpeg", "mpg", "mpe", "mpeg2", "m1v", "m2v", "mp2v", "mpv", "mpv2", "mod", "tod", "vob", "vro", "evob", "evo", "mpeg4", "m4v", "mp4", "mp4v", "mpg4", "m4a", "aac", "h264", "avc", "x264", "264", "hevc", "h265", "x265", "265", "flac", "oga", "ogg", "opus", "spx", "ogv", "ogm", "ogx", "mkv", "mk3d", "mka", "webm", "weba", "avi", "vfw", "divx", "3iv", "xvid", "nut", "flic", "fli", "flc", "nsv", "gxf", "mxf", "wma", "wm", "wmv", "asf", "dvr-ms", "dvr", "wtv", "dv", "hdv", "flv","f4v", "f4a", "qt", "mov", "hdmov", "rm", "rmvb", "ra", "ram", "3ga", "3ga2", "3gpp", "3gp", "3gp2", "3g2", "ay", "gbs", "gym", "hes", "kss", "nsf", "nsfe", "sap", "spc", "vgm", "vgz", "m3u", "m3u8", "pls", "cue", + "ase", "art", "bmp", "blp", "cd5", "cit", "cpt", "cr2", "cut", "dds", "dib", "djvu", "egt", "exif", "gif", "gpl", "grf", "icns", "ico", "iff", "jng", "jpeg", "jpg", "jfif", "jp2", "jps", "lbm", "max", "miff", "mng", "msp", "nitf", "ota", "pbm", "pc1", "pc2", "pc3", "pcf", "pcx", "pdn", "pgm", "PI1", "PI2", "PI3", "pict", "pct", "pnm", "pns", "ppm", "psb", "psd", "pdd", "psp", "px", "pxm", "pxr", "qfx", "raw", "rle", "sct", "sgi", "rgb", "int", "bw", "tga", "tiff", "tif", "vtf", "xbm", "xcf", "xpm", "3dv", "amf", "ai", "awg", "cgm", "cdr", "cmx", "dxf", "e2d", "egt", "eps", "fs", "gbr", "odg", "svg", "stl", "vrml", "x3d", "sxd", "v2d", "vnd", "wmf", "emf", "art", "xar", "png", "webp", "jxr", "hdp", "wdp", "cur", "ecw", "iff", "lbm", "liff", "nrrd", "pam", "pcx", "pgf", "sgi", "rgb", "rgba", "bw", "int", "inta", "sid", "ras", "sun", "tga", + "torrent"] + ]], --add above (after a comma) any extension you want paste to work with; e.g: ,'pdf'. Or set it as "" by deleting all defined extension to make paste works with any extension. + paste_subtitles = [[ + ["aqt", "gsub", "jss", "sub", "ttxt", "pjs", "psb", "rt", "smi", "slt", "ssf", "srt", "ssa", "ass", "usf", "idx", "vtt"] + ]], --add above (after a comma) any extension you want paste to attempt to add as a subtitle file, e.g.:'txt'. Or set it as "" by deleting all defined extension to make paste attempt to add any subtitle. + open_list_keybind = [[ + [ ["y", "all"], ["Y", "all"] ] + ]], --Keybind that will be used to open the list along with the specified filter. Available filters: 'all', 'copy', 'paste', 'recents', 'distinct', 'protocols', 'fileonly', 'titleonly', 'timeonly', 'keywords'. + list_filter_jump_keybind = [[ + [ ["y", "all"], ["Y", "all"], ["r", "recents"], ["R", "recents"], ["d", "distinct"], ["D", "distinct"], ["f", "fileonly"], ["F", "fileonly"] ] + ]], --Keybind that is used while the list is open to jump to the specific filter (it also enables pressing a filter keybind twice to close list). Available fitlers: 'all', 'copy', 'paste', 'recents', 'distinct', 'protocols', 'fileonly', 'titleonly', 'timeonly', 'keywords'. + + -----Logging Settings----- + log_path = "/:dir%mpvconf%", --Change to '/:dir%script%' for placing it in the same directory of script, OR change to '/:dir%mpvconf%' for mpv portable_config directory. OR write any variable using '/:var' then the variable '/:var%APPDATA%' you can use path also, such as: '/:var%APPDATA%\\mpv' OR '/:var%HOME%/mpv' OR specify the absolute path , e.g.: 'C:\\Users\\Eisa01\\Desktop\\' + log_file = "mpvClipboard.log", --name+extension of the file that will be used to store the log data + date_format = "%A/%B %d/%m/%Y %X", --Date format in the log (see lua date formatting), e.g.:'%d/%m/%y %X' or '%d/%b/%y %X' + file_title_logging = "protocols", --Change between 'all', 'protocols', 'none'. This option will store the media title in log file, it is useful for websites / protocols because title cannot be parsed from links alone + logging_protocols = [[ + ["https?://", "magnet:", "rtmp:"] + ]], --add above (after a comma) any protocol you want its title to be stored in the log file. This is valid only for (file_title_logging = 'protocols' or file_title_logging = 'all') + prefer_filename_over_title = "local", --Prefers to copy and log filename over filetitle. Select between 'local', 'protocols', 'all', and 'none'. 'local' prefer filenames for videos that are not protocols. 'protocols' will prefer filenames for protocols only. 'all' will prefer filename over filetitle for both protocols and not protocols videos. 'none' will always use filetitle instead of filename + same_entry_limit = 4, --Limit saving entries with same path: -1 for unlimited, 0 will always update entries of same path, e.g. value of 3 will have the limit of 3 then it will start updating old values on the 4th entry. + + -----List Settings----- + loop_through_list = false, --true is for going up on the first item loops towards the last item and vise-versa. false disables this behavior. + list_middle_loader = true, --false is for more items to show, then u must reach the end. true is for new items to show after reaching the middle of list. + show_paths = false, --Show file paths instead of media-title + show_item_number = true, --Show the number of each item before displaying its name and values. + slice_longfilenames = false, --Change to true or false. Slices long filenames per the amount specified below + slice_longfilenames_amount = 55, --Amount for slicing long filenames + list_show_amount = 10, --Change maximum number to show items at once + quickselect_0to9_keybind = true, --Keybind entries from 0 to 9 for quick selection when list is open (list_show_amount = 10 is maximum for this feature to work) + main_list_keybind_twice_exits = true, --Will exit the list when double tapping the main list, even if the list was accessed through a different filter. + search_not_typing_smartly = true, --To smartly set the search as not typing (when search box is open) without needing to press ctrl+enter. + search_behavior = "any", --'specific' to find a match of either a date, title, path / url, time. 'any' to find any typed search based on combination of date, title, path / url, and time. 'any-notime' to find any typed search based on combination of date, title, and path / url, but without looking for time (this is to reduce unwanted results). + + -----Filter Settings------ + --available filters: "all" to display all the items. Or "copy" to display copied items. Or "paste" to display pasted items. Or "recents" to display recently added items to log without duplicate. Or "distinct" to show recent saved entries for files in different paths. Or "fileonly" to display files saved without time. Or "timeonly" to display files that have time only. Or "keywords" to display files with matching keywords specified in the configuration. Or "playing" to show list of current playing file. + filters_and_sequence = [[ + ["all", "copy", "paste", "recents", "distinct", "protocols", "playing", "fileonly", "titleonly", "keywords"] + ]], --Jump to the following filters and in the shown sequence when navigating via left and right keys. You can change the sequence and delete filters that are not needed. + next_filter_sequence_keybind = [[ + ["RIGHT", "MBTN_FORWARD"] + ]], --Keybind that will be used to go to the next available filter based on the filters_and_sequence + previous_filter_sequence_keybind = [[ + ["LEFT", "MBTN_BACK"] + ]], --Keybind that will be used to go to the previous available filter based on the filters_and_sequence + loop_through_filters = true, --true is for bypassing the last filter to go to first filter when navigating through filters using arrow keys, and vice-versa. false disables this behavior. + keywords_filter_list = [[ + [""] + ]], --Create a filter out of your desired 'keywords', e.g.: youtube.com will filter out the videos from youtube. You can also insert a portion of filename or title, or extension or a full path / portion of a path. e.g.: ["youtube.com", "mp4", "naruto", "c:\\users\\eisa01\\desktop"] + + -----Sort Settings------ + --available sort: 'added-asc' is for the newest added item to show first. Or 'added-desc' for the newest added to show last. Or 'alphanum-asc' is for A to Z approach with filename and episode number lower first. Or 'alphanum-desc' is for its Z to A approach. Or 'time-asc', 'time-desc' to sort the list based on time. + list_default_sort = "added-asc", --the default sorting method for all the different filters in the list. select between 'added-asc', 'added-desc', 'time-asc', 'time-desc', 'alphanum-asc', 'alphanum-desc' + list_filters_sort = [[ + [ ] + ]], --Default sort for specific filters, e.g.: [ ["all", "alphanum-asc"], ["playing", "added-desc"] ] + list_cycle_sort_keybind = [[ + ["alt+s", "alt+S"] + ]], --Keybind to cycle through the different available sorts when list is open + + -----List Design Settings----- + list_alignment = 7, --The alignment for the list, uses numpad positions choose from 1-9 or 0 to disable. e,g.:7 top left alignment, 8 top middle alignment, 9 top right alignment. + text_time_type = "duration", --The time type for items on the list. Select between 'duration', 'length', 'remaining'. + time_seperator = " 🕒 ", --Time seperator that will be used before the saved time + list_sliced_prefix = "...\\h\\N\\N", --The text that indicates there are more items above. \\h\\N\\N is for new line. + list_sliced_suffix = "...", --The text that indicates there are more items below. + quickselect_0to9_pre_text = false, --true enables pre text for showing quickselect keybinds before the list. false to disable + text_color = "ffffff", --Text color for list in BGR hexadecimal + text_scale = 50, --Font size for the text of list + text_border = 0.7, --Black border size for the text of list + text_cursor_color = "ffbf7f", --Highlight color in BGR hexadecimal + text_cursor_scale = 50, --Font size for highlighted text in list + text_cursor_border = 0.7, --Black border size for highlighted text in list + text_highlight_pre_text = "✅ ", --Pre text for highlighted multi-select item + search_color_typing = "ffffaa", --Search color when in typing mode + search_color_not_typing = "56ffaa", --Search color when not in typing mode and it is active + header_color = "56ffaa", --Header color in BGR hexadecimal + header_scale = 55, --Header text size for the list + header_border = 0.8, --Black border size for the Header of list + header_text = "📋 Clipboard [%cursor%/%total%]%prehighlight%%highlight%%afterhighlight%%prefilter%%filter%%afterfilter%%presort%%sort%%aftersort%%presearch%%search%%aftersearch%", --Text to be shown as header for the list + --Available header variables: %cursor%, %total%, %highlight%, %filter%, %search%, %listduration%, %listlength%, %listremaining% + --User defined text that only displays if a variable is triggered: %prefilter%, %afterfilter%, %prehighlight%, %afterhighlight% %presearch%, %aftersearch%, %prelistduration%, %afterlistduration%, %prelistlength%, %afterlistlength%, %prelistremaining%, %afterlistremaining% + --Variables explanation: %cursor: displays the number of cursor position in list. %total: total amount of items in current list. %highlight%: total number of highlighted items. %filter: shows the filter name, %search: shows the typed search. Example of user defined text that only displays if a variable is triggered of user: %prefilter: user defined text before showing filter, %afterfilter: user defined text after showing filter. + header_sort_hide_text = "added-asc", --Sort method that is hidden from header when using %sort% variable + header_sort_pre_text = " \\{", --Text to be shown before sort in the header, when using %presort% + header_sort_after_text = "}", --Text to be shown after sort in the header, when using %aftersort% + header_filter_pre_text = " [Filter: ", --Text to be shown before filter in the header, when using %prefilter% + header_filter_after_text = "]", --Text to be shown after filter in the header, when using %afterfilter% + header_search_pre_text = "\\h\\N\\N[Search=", --Text to be shown before search in the header, when using %presearch% + header_search_after_text = "..]", --Text to be shown after search in the header, when using %aftersearch% + header_highlight_pre_text = "✅", --Text to be shown before total highlighted items of displayed list in the header + header_highlight_after_text = "", --Text to be shown after total highlighted items of displayed list in the header + header_list_duration_pre_text = " 🕒 ", --Text to be shown before playback total duration of displayed list in the header + header_list_duration_after_text = "", --Text to be shown after playback total duration of displayed list in the header + header_list_length_pre_text = " 🕒 ", --Text to be shown before playback total duration of displayed list in the header + header_list_length_after_text = "", --Text to be shown after playback total duration of displayed list in the header + header_list_remaining_pre_text = " 🕒 ", --Text to be shown before playback total duration of displayed list in the header + header_list_remaining_after_text = "", --Text to be shown after playback total duration of displayed list in the header + copy_seperator = " ©", --Copy seperator that will be shown for copied items in the list + paste_seperator = " ℗", --Paste seperator that will be shown for pasted item in the list + + -----Time Format Settings----- + --in the first parameter, you can define from the available styles: default, hms, hms-full, timestamp, timestamp-concise "default" to show in HH:MM:SS.sss format. "hms" to show in 1h 2m 3.4s format. "hms-full" is the same as hms but keeps the hours and minutes persistent when they are 0. "timestamp" to show the total time as timestamp 123456.700 format. "timestamp-concise" shows the total time in 123456.7 format (shows and hides decimals depending on availability). + --in the second parameter, you can define whether to show milliseconds, round them or truncate them. Available options: 'truncate' to remove the milliseconds and keep the seconds. 0 to remove the milliseconds and round the seconds. 1 or above is the amount of milliseconds to display. The default value is 3 milliseconds. + --in the third parameter you can define the seperator between hour:minute:second. "default" style is automatically set to ":", "hms", "hms-full" are automatically set to " ". You can define your own. Some examples: ["default", 3, "-"],["hms-full", 5, "."],["hms", "truncate", ":"],["timestamp-concise"],["timestamp", 0],["timestamp", "truncate"],["timestamp", 5] + copy_time_format = [[ + ["timestamp-concise"] + ]], + osd_time_format = [[ + ["default", "truncate"] + ]], + list_time_format = [[ + ["default", "truncate"] + ]], + header_duration_time_format = [[ + ["hms", "truncate", ":"] + ]], + header_length_time_format = [[ + ["hms", "truncate", ":"] + ]], + header_remaining_time_format = [[ + ["hms", "truncate", ":"] + ]], + + -----List Keybind Settings----- + --Add below (after a comma) any additional keybind you want to bind. Or change the letter inside the quotes to change the keybind + --Example of changing and adding keybinds: --From ["b", "B"] To ["b"]. --From [""] to ["alt+b"]. --From [""] to ["a" "ctrl+a", "alt+a"] + list_move_up_keybind = [[ + ["UP", "WHEEL_UP"] + ]], --Keybind that will be used to navigate up on the list + list_move_down_keybind = [[ + ["DOWN", "WHEEL_DOWN"] + ]], --Keybind that will be used to navigate down on the list + list_page_up_keybind = [[ + ["PGUP"] + ]], --Keybind that will be used to go to the first item for the page shown on the list + list_page_down_keybind = [[ + ["PGDWN"] + ]], --Keybind that will be used to go to the last item for the page shown on the list + list_move_first_keybind = [[ + ["HOME"] + ]], --Keybind that will be used to navigate to the first item on the list + list_move_last_keybind = [[ + ["END"] + ]], --Keybind that will be used to navigate to the last item on the list + list_highlight_move_keybind = [[ + ["SHIFT"] + ]], --Keybind that will be used to highlight while pressing a navigational keybind, keep holding shift and then press any navigation keybind, such as: up, down, home, pgdwn, etc.. + list_highlight_all_keybind = [[ + ["ctrl+a", "ctrl+A"] + ]], --Keybind that will be used to highlight all displayed items on the list + list_unhighlight_all_keybind = [[ + ["ctrl+d", "ctrl+D"] + ]], --Keybind that will be used to remove all currently highlighted items from the list + list_select_keybind = [[ + ["ENTER", "MBTN_MID"] + ]], --Keybind that will be used to load entry based on cursor position + list_add_playlist_keybind = [[ + ["CTRL+ENTER"] + ]], --Keybind that will be used to add entry to playlist based on cursor position + list_add_playlist_highlighted_keybind = [[ + ["SHIFT+ENTER"] + ]], --Keybind that will be used to add all highlighted entries to playlist + list_close_keybind = [[ + ["ESC", "MBTN_RIGHT"] + ]], --Keybind that will be used to close the list (closes search first if it is open) + list_delete_keybind = [[ + ["DEL"] + ]], --Keybind that will be used to delete the entry based on cursor position + list_delete_highlighted_keybind = [[ + ["SHIFT+DEL"] + ]], --Keybind that will be used to delete all highlighted entries from the list + list_search_activate_keybind = [[ + ["ctrl+f", "ctrl+F"] + ]], --Keybind that will be used to trigger search + list_search_not_typing_mode_keybind = [[ + ["ALT+ENTER"] + ]], --Keybind that will be used to exit typing mode of search while keeping search open + list_ignored_keybind = [[ + ["h", "H", "r", "R", "b", "B", "k", "K"] + ]], --Keybind thats are ignored when list is open + + ---------------------------END OF USER CUSTOMIZATION SETTINGS--------------------------- +}; + +(require("mp.options")).read_options(o) +local utils = require("mp.utils") +local msg = require("mp.msg") + +o.copy_keybind = utils.parse_json(o.copy_keybind) +o.paste_keybind = utils.parse_json(o.paste_keybind) +o.copy_specific_keybind = utils.parse_json(o.copy_specific_keybind) +o.paste_specific_keybind = utils.parse_json(o.paste_specific_keybind) +o.paste_protocols = utils.parse_json(o.paste_protocols) +o.paste_extensions = utils.parse_json(o.paste_extensions) +o.paste_subtitles = utils.parse_json(o.paste_subtitles) +o.specific_time_attributes = utils.parse_json(o.specific_time_attributes) +o.pastable_time_attributes = utils.parse_json(o.pastable_time_attributes) +o.filters_and_sequence = utils.parse_json(o.filters_and_sequence) +o.keywords_filter_list = utils.parse_json(o.keywords_filter_list) +o.list_filters_sort = utils.parse_json(o.list_filters_sort) +o.logging_protocols = utils.parse_json(o.logging_protocols) +o.copy_time_format = utils.parse_json(o.copy_time_format) +o.osd_time_format = utils.parse_json(o.osd_time_format) +o.list_time_format = utils.parse_json(o.list_time_format) +o.header_duration_time_format = utils.parse_json(o.header_duration_time_format) +o.header_length_time_format = utils.parse_json(o.header_length_time_format) +o.header_remaining_time_format = utils.parse_json(o.header_remaining_time_format) +o.list_move_up_keybind = utils.parse_json(o.list_move_up_keybind) +o.list_move_down_keybind = utils.parse_json(o.list_move_down_keybind) +o.list_page_up_keybind = utils.parse_json(o.list_page_up_keybind) +o.list_page_down_keybind = utils.parse_json(o.list_page_down_keybind) +o.list_move_first_keybind = utils.parse_json(o.list_move_first_keybind) +o.list_move_last_keybind = utils.parse_json(o.list_move_last_keybind) +o.list_highlight_move_keybind = utils.parse_json(o.list_highlight_move_keybind) +o.list_highlight_all_keybind = utils.parse_json(o.list_highlight_all_keybind) +o.list_unhighlight_all_keybind = utils.parse_json(o.list_unhighlight_all_keybind) +o.list_cycle_sort_keybind = utils.parse_json(o.list_cycle_sort_keybind) +o.list_select_keybind = utils.parse_json(o.list_select_keybind) +o.list_add_playlist_keybind = utils.parse_json(o.list_add_playlist_keybind) +o.list_add_playlist_highlighted_keybind = utils.parse_json(o.list_add_playlist_highlighted_keybind) +o.list_close_keybind = utils.parse_json(o.list_close_keybind) +o.list_delete_keybind = utils.parse_json(o.list_delete_keybind) +o.list_delete_highlighted_keybind = utils.parse_json(o.list_delete_highlighted_keybind) +o.list_search_activate_keybind = utils.parse_json(o.list_search_activate_keybind) +o.list_search_not_typing_mode_keybind = utils.parse_json(o.list_search_not_typing_mode_keybind) +o.next_filter_sequence_keybind = utils.parse_json(o.next_filter_sequence_keybind) +o.previous_filter_sequence_keybind = utils.parse_json(o.previous_filter_sequence_keybind) +o.open_list_keybind = utils.parse_json(o.open_list_keybind) +o.list_filter_jump_keybind = utils.parse_json(o.list_filter_jump_keybind) +o.list_ignored_keybind = utils.parse_json(o.list_ignored_keybind) + +if utils.shared_script_property_set then + utils.shared_script_property_set("smartcopypaste-menu-open", "no") +end +mp.set_property("user-data/smartcopypaste/menu-open", "no") + +if string.lower(o.log_path) == "/:dir%mpvconf%" then + o.log_path = mp.find_config_file(".") +elseif string.lower(o.log_path) == "/:dir%script%" then + o.log_path = debug.getinfo(1).source:match("@?(.*/)") +elseif o.log_path:match("/:var%%(.*)%%") then + local os_variable = o.log_path:match("/:var%%(.*)%%") + o.log_path = o.log_path:gsub("/:var%%(.*)%%", os.getenv(os_variable)) +end +local log_fullpath = utils.join_path(o.log_path, o.log_file) + +local log_length_text = "length=" +local log_time_text = "time=" +local log_clipboard_text = "clip=" +local protocols = { "https?:", "magnet:", "rtmps?:", "smb:", "ftps?:", "sftp:" } +local available_filters = { + "all", + "copy", + "paste", + "recents", + "distinct", + "playing", + "protocols", + "fileonly", + "titleonly", + "timeonly", + "keywords", +} +local available_sorts = { "added-asc", "added-desc", "time-asc", "time-desc", "alphanum-asc", "alphanum-desc" } +local search_string = "" +local search_active = false + +local resume_selected = false +local list_contents = {} +local list_start = 0 +local list_cursor = 1 +local list_highlight_cursor = {} +local list_drawn = false +local list_pages = {} +local filePath, fileTitle, fileLength +local seekTime = 0 +local filterName = "all" +local sortName + +function starts_protocol(tab, val) + for index, value in ipairs(tab) do + if val:find(value) == 1 then + return true + end + end + return false +end + +function contain_value(tab, val) + if not tab then + return msg.error("check value passed") + end + if not val then + return msg.error("check value passed") + end + + for index, value in ipairs(tab) do + if value.match(string.lower(val), string.lower(value)) then + return true + end + end + + return false +end + +function has_value(tab, val, array2d) + if not tab then + return msg.error("check value passed") + end + if not val then + return msg.error("check value passed") + end + if not array2d then + for index, value in ipairs(tab) do + if string.lower(value) == string.lower(val) then + return true + end + end + end + if array2d then + for i = 1, #tab do + if tab[i] and string.lower(tab[i][array2d]) == string.lower(val) then + return true + end + end + end + + return false +end + +function file_exists(name) + local f = io.open(name, "r") + if f ~= nil then + io.close(f) + return true + else + return false + end +end + +function format_time(seconds, sep, decimals, style) + local function divmod(a, b) + return math.floor(a / b), a % b + end + decimals = decimals == nil and 3 or decimals + + local s = seconds + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + if decimals == "truncate" then + s = math.floor(s) + decimals = 0 + if style == "timestamp" then + seconds = math.floor(seconds) + end + end + + if not style or style == "" or style == "default" then + local second_format = string.format("%%0%d.%df", 2 + (decimals > 0 and decimals + 1 or 0), decimals) + sep = sep and sep or ":" + return string.format("%02d" .. sep .. "%02d" .. sep .. second_format, h, m, s) + elseif style == "hms" or style == "hms-full" then + sep = sep ~= nil and sep or " " + if style == "hms-full" or h > 0 then + return string.format("%dh" .. sep .. "%dm" .. sep .. "%." .. tostring(decimals) .. "fs", h, m, s) + elseif m > 0 then + return string.format("%dm" .. sep .. "%." .. tostring(decimals) .. "fs", m, s) + else + return string.format("%." .. tostring(decimals) .. "fs", s) + end + elseif style == "timestamp" then + return string.format("%." .. tostring(decimals) .. "f", seconds) + elseif style == "timestamp-concise" then + return seconds + end +end + +function get_file() + local path = mp.get_property("path") + if not path then + return + end + + local length = (mp.get_property_number("duration") or 0) + + local title = mp.get_property("media-title"):gsub('"', "") + + if starts_protocol(o.logging_protocols, path) and o.prefer_filename_over_title == "protocols" then + title = mp.get_property("filename"):gsub('"', "") + elseif not starts_protocol(o.logging_protocols, path) and o.prefer_filename_over_title == "local" then + title = mp.get_property("filename"):gsub('"', "") + elseif o.prefer_filename_over_title == "all" then + title = mp.get_property("filename"):gsub('"', "") + end + + return path, title, length +end + +function bind_keys(keys, name, func, opts) + if not keys then + mp.add_forced_key_binding(keys, name, func, opts) + return + end + + for i = 1, #keys do + if i == 1 then + mp.add_forced_key_binding(keys[i], name, func, opts) + else + mp.add_forced_key_binding(keys[i], name .. i, func, opts) + end + end +end + +function unbind_keys(keys, name) + if not keys then + mp.remove_key_binding(name) + return + end + + for i = 1, #keys do + if i == 1 then + mp.remove_key_binding(name) + else + mp.remove_key_binding(name .. i) + end + end +end + +function esc_string(str) + return str:gsub("([%p])", "%%%1") +end + +---------Start of LogManager--------- +--LogManager (Read and Format the List from Log)-- +function read_log(func) + local f = io.open(log_fullpath, "r") + if not f then + return + end + local contents = {} + for line in f:lines() do + table.insert(contents, (func(line))) + end + f:close() + return contents +end + +function read_log_table() + local line_pos = 0 + return read_log(function(line) + local tt, p, t, s, d, n, e, l, dt, ln, r, cp, pt + if line:match('^.-"(.-)"') then + tt = line:match('^.-"(.-)"') + n, p = line:match('^.-"(.-)" | (.*) | ' .. esc_string(log_length_text) .. "(.*)") + else + p = line:match("[(.*)%]]%s(.*) | " .. esc_string(log_length_text) .. "(.*)") + d, n, e = p:match("^(.-)([^\\/]-)%.([^\\/%.]-)%.?$") + end + dt = line:match("%[(.-)%]") + t = line:match(" | " .. esc_string(log_time_text) .. "(%d*%.?%d*)(.*)$") + ln = line:match(" | " .. esc_string(log_length_text) .. "(%d*%.?%d*)(.*)$") + if tonumber(ln) and tonumber(t) then + r = tonumber(ln) - tonumber(t) + else + r = 0 + end + cp = line:match(" | .* | " .. esc_string(log_clipboard_text) .. "(copy)$") + pt = line:match(" | .* | " .. esc_string(log_clipboard_text) .. "(paste)$") + l = line + line_pos = line_pos + 1 + return { + found_path = p, + found_time = t, + found_name = n, + found_title = tt, + found_line = l, + found_sequence = line_pos, + found_directory = d, + found_datetime = dt, + found_length = ln, + found_remaining = r, + found_copy = cp, + found_paste = pt, + } + end) +end + +function list_sort(tab, sort) + if sort == "added-asc" then + table.sort(tab, function(a, b) + return a["found_sequence"] < b["found_sequence"] + end) + elseif sort == "added-desc" then + table.sort(tab, function(a, b) + return a["found_sequence"] > b["found_sequence"] + end) + elseif sort == "time-asc" then + table.sort(tab, function(a, b) + return tonumber(a["found_time"]) > tonumber(b["found_time"]) + end) + elseif sort == "time-desc" then + table.sort(tab, function(a, b) + return tonumber(a["found_time"]) < tonumber(b["found_time"]) + end) + elseif sort == "alphanum-asc" or sort == "alphanum-desc" then + 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 + if sort == "alphanum-asc" then + table.sort(tab, function(a, b) + return tostring(a["found_path"]):gsub("%.?%d+", padnum) .. ("%3d"):format(#b) + > tostring(b["found_path"]):gsub("%.?%d+", padnum) .. ("%3d"):format(#a) + end) + elseif sort == "alphanum-desc" then + table.sort(tab, function(a, b) + return tostring(a["found_path"]):gsub("%.?%d+", padnum) .. ("%3d"):format(#b) + < tostring(b["found_path"]):gsub("%.?%d+", padnum) .. ("%3d"):format(#a) + end) + end + end + + return tab +end + +function parse_header(string) + local osd_header_color = string.format("{\\1c&H%s}", o.header_color) + local osd_search_color = osd_header_color + if search_active == "typing" then + osd_search_color = string.format("{\\1c&H%s}", o.search_color_typing) + elseif search_active == "not_typing" then + osd_search_color = string.format("{\\1c&H%s}", o.search_color_not_typing) + end + local osd_msg_end = "{\\1c&HFFFFFF}" + + string = string:gsub("%%total%%", #list_contents):gsub("%%cursor%%", list_cursor) + + if filterName ~= "all" then + string = string + :gsub("%%filter%%", filterName) + :gsub("%%prefilter%%", o.header_filter_pre_text) + :gsub("%%afterfilter%%", o.header_filter_after_text) + else + string = string:gsub("%%filter%%", ""):gsub("%%prefilter%%", ""):gsub("%%afterfilter%%", "") + end + + local list_total_duration = 0 + if string:match("%listduration%%") then + list_total_duration = get_total_duration("found_time") + if list_total_duration > 0 then + string = string:gsub( + "%%listduration%%", + format_time( + list_total_duration, + o.header_duration_time_format[3], + o.header_duration_time_format[2], + o.header_duration_time_format[1] + ) + ) + else + string = string:gsub("%%listduration%%", "") + end + end + if list_total_duration > 0 then + string = string + :gsub("%%prelistduration%%", o.header_list_duration_pre_text) + :gsub("%%afterlistduration%%", o.header_list_duration_after_text) + else + string = string:gsub("%%prelistduration%%", ""):gsub("%%afterlistduration%%", "") + end + + local list_total_length = 0 + if string:match("%listlength%%") then + list_total_length = get_total_duration("found_length") + if list_total_length > 0 then + string = string:gsub( + "%%listlength%%", + format_time( + list_total_length, + o.header_length_time_format[3], + o.header_length_time_format[2], + o.header_length_time_format[1] + ) + ) + else + string = string:gsub("%%listlength%%", "") + end + end + if list_total_length > 0 then + string = string + :gsub("%%prelistlength%%", o.header_list_length_pre_text) + :gsub("%%afterlistlength%%", o.header_list_length_after_text) + else + string = string:gsub("%%prelistlength%%", ""):gsub("%%afterlistlength%%", "") + end + + local list_total_remaining = 0 + if string:match("%listremaining%%") then + list_total_remaining = get_total_duration("found_remaining") + if list_total_remaining > 0 then + string = string:gsub( + "%%listremaining%%", + format_time( + list_total_remaining, + o.header_remaining_time_format[3], + o.header_remaining_time_format[2], + o.header_remaining_time_format[1] + ) + ) + else + string = string:gsub("%%listremaining%%", "") + end + end + if list_total_remaining > 0 then + string = string + :gsub("%%prelistremaining%%", o.header_list_remaining_pre_text) + :gsub("%%afterlistremaining%%", o.header_list_remaining_after_text) + else + string = string:gsub("%%prelistremaining%%", ""):gsub("%%afterlistremaining%%", "") + end + + if #list_highlight_cursor > 0 then + string = string + :gsub("%%highlight%%", #list_highlight_cursor) + :gsub("%%prehighlight%%", o.header_highlight_pre_text) + :gsub("%%afterhighlight%%", o.header_highlight_after_text) + else + string = string:gsub("%%highlight%%", ""):gsub("%%prehighlight%%", ""):gsub("%%afterhighlight%%", "") + end + + if sortName and sortName ~= o.header_sort_hide_text then + string = string + :gsub("%%sort%%", sortName) + :gsub("%%presort%%", o.header_sort_pre_text) + :gsub("%%aftersort%%", o.header_sort_after_text) + else + string = string:gsub("%%sort%%", ""):gsub("%%presort%%", ""):gsub("%%aftersort%%", "") + end + + if search_active then + local search_string_osd = search_string + if search_string_osd ~= "" then + search_string_osd = search_string:gsub("%%", "%%%%%%%%"):gsub("\\", "\\"):gsub("{", "\\{") + end + + string = string + :gsub("%%search%%", osd_search_color .. search_string_osd .. osd_header_color) + :gsub("%%presearch%%", o.header_search_pre_text) + :gsub("%%aftersearch%%", o.header_search_after_text) + else + string = string:gsub("%%search%%", ""):gsub("%%presearch%%", ""):gsub("%%aftersearch%%", "") + end + string = string:gsub("%%%%", "%%") + return string +end + +function get_list_contents(filter, sort) + if not filter then + filter = filterName + end + if not sort then + sort = get_list_sort(filter) + end + + local current_sort + + local filtered_table = {} + + local prev_list_contents + if list_contents ~= nil and list_contents[1] then + prev_list_contents = list_contents + else + prev_list_contents = read_log_table() + end + + list_contents = read_log_table() + if not list_contents and not search_active or not list_contents[1] and not search_active then + return + end + current_sort = "added-asc" + + if filter == "copy" then + for i = 1, #list_contents do + if list_contents[i].found_copy then + table.insert(filtered_table, list_contents[i]) + end + end + + if not sort then + active_sort = o.sort_copy_filter + end + if active_sort ~= "none" or active_sort ~= "" then + list_sort(filtered_table, active_sort) + end + + list_contents = filtered_table + end + + if filter == "paste" then + for i = 1, #list_contents do + if list_contents[i].found_paste then + table.insert(filtered_table, list_contents[i]) + end + end + + if not sort then + active_sort = o.sort_paste_filter + end + if active_sort ~= "none" or active_sort ~= "" then + list_sort(filtered_table, active_sort) + end + + list_contents = filtered_table + end + + if filter == "recents" then + table.sort(list_contents, function(a, b) + return a["found_sequence"] < b["found_sequence"] + end) + local unique_values = {} + local list_total = #list_contents + + if + filePath == list_contents[#list_contents].found_path + and tonumber(list_contents[#list_contents].found_time) == 0 + then + list_total = list_total - 1 + end + + for i = list_total, 1, -1 do + if not has_value(unique_values, list_contents[i].found_path) then + table.insert(unique_values, list_contents[i].found_path) + table.insert(filtered_table, list_contents[i]) + end + end + table.sort(filtered_table, function(a, b) + return a["found_sequence"] < b["found_sequence"] + end) + + list_contents = filtered_table + end + + if filter == "distinct" then + table.sort(list_contents, function(a, b) + return a["found_sequence"] < b["found_sequence"] + end) + local unique_values = {} + local list_total = #list_contents + + if + filePath == list_contents[#list_contents].found_path + and tonumber(list_contents[#list_contents].found_time) == 0 + then + list_total = list_total - 1 + end + + for i = list_total, 1, -1 do + if + list_contents[i].found_directory + and not has_value(unique_values, list_contents[i].found_directory) + and not starts_protocol(protocols, list_contents[i].found_path) + then + table.insert(unique_values, list_contents[i].found_directory) + table.insert(filtered_table, list_contents[i]) + end + end + table.sort(filtered_table, function(a, b) + return a["found_sequence"] < b["found_sequence"] + end) + + list_contents = filtered_table + end + + if filter == "fileonly" then + for i = 1, #list_contents do + if tonumber(list_contents[i].found_time) == 0 then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if filter == "timeonly" then + for i = 1, #list_contents do + if tonumber(list_contents[i].found_time) > 0 then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if filter == "titleonly" then + for i = 1, #list_contents do + if list_contents[i].found_title then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if filter == "protocols" then + for i = 1, #list_contents do + if starts_protocol(o.logging_protocols, list_contents[i].found_path) then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if filter == "keywords" then + for i = 1, #list_contents do + if contain_value(o.keywords_filter_list, list_contents[i].found_line) then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if filter == "playing" then + for i = 1, #list_contents do + if list_contents[i].found_path == filePath then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if search_active and search_string ~= "" then + local filtered_table = {} + + local search_query = "" + for search in search_string:gmatch("[^%s]+") do + search_query = search_query .. ".-" .. esc_string(search) + end + + local contents_string = "" + for i = 1, #list_contents do + if o.search_behavior == "specific" then + if string.lower(list_contents[i].found_path):match(string.lower(search_query)) then + table.insert(filtered_table, list_contents[i]) + elseif + list_contents[i].found_title + and string.lower(list_contents[i].found_title):match(string.lower(search_query)) + then + table.insert(filtered_table, list_contents[i]) + elseif + tonumber(list_contents[i].found_time) > 0 + and format_time( + list_contents[i].found_time, + o.osd_time_format[3], + o.osd_time_format[2], + o.osd_time_format[1] + ):match(search_query) + then + table.insert(filtered_table, list_contents[i]) + elseif string.lower(list_contents[i].found_datetime):match(string.lower(search_query)) then + table.insert(filtered_table, list_contents[i]) + end + elseif o.search_behavior == "any" then + contents_string = list_contents[i].found_datetime + .. (list_contents[i].found_title or "") + .. list_contents[i].found_path + if tonumber(list_contents[i].found_time) > 0 then + contents_string = contents_string + .. format_time( + list_contents[i].found_time, + o.osd_time_format[3], + o.osd_time_format[2], + o.osd_time_format[1] + ) + end + elseif o.search_behavior == "any-notime" then + contents_string = list_contents[i].found_datetime + .. (list_contents[i].found_title or "") + .. list_contents[i].found_path + end + + if string.lower(contents_string):match(string.lower(search_query)) then + table.insert(filtered_table, list_contents[i]) + end + end + + list_contents = filtered_table + end + + if sort ~= current_sort then + list_sort(list_contents, sort) + end + + if not list_contents and not search_active or not list_contents[1] and not search_active then + return + end +end + +function get_list_sort(filter) + if not filter then + filter = filterName + end + + local sort + for i = 1, #o.list_filters_sort do + if o.list_filters_sort[i][1] == filter then + if has_value(available_sorts, o.list_filters_sort[i][2]) then + sort = o.list_filters_sort[i][2] + end + break + end + end + + if not sort and has_value(available_sorts, o.list_default_sort) then + sort = o.list_default_sort + end + + if not sort then + sort = "added-asc" + end + + return sort +end + +function draw_list() + local osd_msg = "" + local osd_index = "" + local osd_key = "" + local osd_color = "" + local key = 0 + local osd_text = string.format( + "{\\an%f{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", + o.list_alignment, + o.text_scale, + o.text_scale, + o.text_border, + o.text_color + ) + local osd_cursor = string.format( + "{\\an%f}{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", + o.list_alignment, + o.text_cursor_scale, + o.text_cursor_scale, + o.text_cursor_border, + o.text_cursor_color + ) + local osd_header = string.format( + "{\\an%f}{\\fscx%f}{\\fscy%f}{\\bord%f}{\\1c&H%s}", + o.list_alignment, + o.header_scale, + o.header_scale, + o.header_border, + o.header_color + ) + local osd_msg_end = "{\\1c&HFFFFFF}" + local osd_time_type = "found_time" + + if o.text_time_type == "length" then + osd_time_type = "found_length" + elseif o.text_time_type == "remaining" then + osd_time_type = "found_remaining" + end + + if o.header_text ~= "" then + osd_msg = osd_msg .. osd_header .. parse_header(o.header_text) + osd_msg = osd_msg .. "\\h\\N\\N" .. osd_msg_end + end + + if search_active and not list_contents[1] then + osd_msg = osd_msg .. "No search results found" .. osd_msg_end + end + + if o.list_middle_loader then + list_start = list_cursor - math.floor(o.list_show_amount / 2) + else + list_start = list_cursor - o.list_show_amount + end + + local showall = false + local showrest = false + if list_start < 0 then + list_start = 0 + end + if #list_contents <= o.list_show_amount then + list_start = 0 + showall = true + end + if list_start > math.max(#list_contents - o.list_show_amount - 1, 0) then + list_start = #list_contents - o.list_show_amount + showrest = true + end + if list_start > 0 and not showall then + osd_msg = osd_msg .. o.list_sliced_prefix .. osd_msg_end + end + for i = list_start, list_start + o.list_show_amount - 1, 1 do + if i == #list_contents then + break + end + + if o.show_paths then + p = list_contents[#list_contents - i].found_path or list_contents[#list_contents - i].found_name or "" + else + p = list_contents[#list_contents - i].found_name or list_contents[#list_contents - i].found_path or "" + end + + if o.slice_longfilenames and p:len() > o.slice_longfilenames_amount then + p = p:sub(1, o.slice_longfilenames_amount) .. "..." + end + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 and o.quickselect_0to9_pre_text then + key = 1 + key + if key == 10 then + key = 0 + end + osd_key = "(" .. key .. ") " + end + + if o.show_item_number then + osd_index = (i + 1) .. ". " + end + + if i + 1 == list_cursor then + osd_color = osd_cursor + else + osd_color = osd_text + end + + for j = 1, #list_highlight_cursor do + if list_highlight_cursor[j] and list_highlight_cursor[j][1] == i + 1 then + osd_msg = osd_msg .. osd_color .. esc_string(o.text_highlight_pre_text) + end + end + + osd_msg = osd_msg .. osd_color .. osd_key .. osd_index .. p + + if + list_contents[#list_contents - i][osd_time_type] + and tonumber(list_contents[#list_contents - i][osd_time_type]) > 0 + then + osd_msg = osd_msg + .. o.time_seperator + .. format_time( + list_contents[#list_contents - i][osd_time_type], + o.list_time_format[3], + o.list_time_format[2], + o.list_time_format[1] + ) + end + + if list_contents[#list_contents - i].found_copy then + osd_msg = osd_msg .. o.copy_seperator + end + + if list_contents[#list_contents - i].found_paste then + osd_msg = osd_msg .. o.paste_seperator + end + + osd_msg = osd_msg .. "\\h\\N\\N" .. osd_msg_end + + if i == list_start + o.list_show_amount - 1 and not showall and not showrest then + osd_msg = osd_msg .. o.list_sliced_suffix + end + end + mp.set_osd_ass(0, 0, osd_msg) +end + +function list_empty_error_msg() + if list_contents ~= nil and list_contents[1] then + return + end + local msg_text + if filterName ~= "all" then + msg_text = filterName .. " filter in Clipboard Empty" + else + msg_text = "Clipboard Empty" + end + msg.info(msg_text) + if o.osd_messages == true and not list_drawn then + mp.osd_message(msg_text) + end +end + +function display_list(filter, sort, action) + if not filter or not has_value(available_filters, filter) then + filter = "all" + end + if not sortName then + sortName = get_list_sort(filter) + end + + local prev_sort = sortName + if not has_value(available_sorts, prev_sort) then + prev_sort = get_list_sort() + end + + if not sort then + sort = get_list_sort(filter) + end + sortName = sort + + local prev_filter = filterName + filterName = filter + + get_list_contents(filter, sort) + + if action ~= "hide-osd" then + if not list_contents or not list_contents[1] then + list_empty_error_msg() + filterName = prev_filter + get_list_contents(filterName) + return + end + end + if not list_contents and not search_active or not list_contents[1] and not search_active then + return + end + + if not has_value(o.filters_and_sequence, filter) then + table.insert(o.filters_and_sequence, filter) + end + + local insert_new = false + + local trigger_close_list = false + local trigger_initial_list = false + + if not list_pages or not list_pages[1] then + table.insert(list_pages, { filter, 1, 1, {}, sort }) + else + for i = 1, #list_pages do + if list_pages[i][1] == filter then + list_pages[i][3] = list_pages[i][3] + 1 + insert_new = false + break + else + insert_new = true + end + end + end + + if insert_new then + table.insert(list_pages, { filter, 1, 1, {}, sort }) + end + + for i = 1, #list_pages do + if not search_active and list_pages[i][1] == prev_filter then + list_pages[i][2] = list_cursor + list_pages[i][4] = list_highlight_cursor + list_pages[i][5] = prev_sort + end + if list_pages[i][1] ~= filter then + list_pages[i][3] = 0 + end + if list_pages[i][3] == 2 and filter == "all" and o.main_list_keybind_twice_exits then + trigger_close_list = true + elseif list_pages[i][3] == 2 and list_pages[1][1] == filter then + trigger_close_list = true + elseif list_pages[i][3] == 2 then + trigger_initial_list = true + end + end + + if trigger_initial_list then + display_list(list_pages[1][1], nil, "hide-osd") + return + end + + if trigger_close_list then + list_close_and_trash_collection() + return + end + + if not search_active then + get_page_properties(filter) + else + update_search_results("", "") + end + draw_list() + if utils.shared_script_property_set then + utils.shared_script_property_set("smartcopypaste-menu-open", "yes") + end + mp.set_property("user-data/smartcopypaste/menu-open", "yes") + if o.toggle_idlescreen then + mp.commandv("script-message", "osc-idlescreen", "no", "no_osd") + end + list_drawn = true + if not search_active then + get_list_keybinds() + end +end + +--End of LogManager (Read and Format the List from Log)-- + +--LogManager Navigation-- +function select(pos, action) + if not search_active then + if not list_contents or not list_contents[1] then + list_close_and_trash_collection() + return + end + end + + local list_cursor_temp = list_cursor + pos + if list_cursor_temp > 0 and list_cursor_temp <= #list_contents then + list_cursor = list_cursor_temp + + if action == "highlight" then + if not has_value(list_highlight_cursor, list_cursor, 1) then + if pos > -1 then + for i = pos, 1, -1 do + if not has_value(list_highlight_cursor, list_cursor - i, 1) then + table.insert( + list_highlight_cursor, + { list_cursor - i, list_contents[#list_contents + 1 + i - list_cursor] } + ) + end + end + else + for i = pos, -1, 1 do + if not has_value(list_highlight_cursor, list_cursor - i, 1) then + table.insert( + list_highlight_cursor, + { list_cursor - i, list_contents[#list_contents + 1 + i - list_cursor] } + ) + end + end + end + table.insert(list_highlight_cursor, { list_cursor, list_contents[#list_contents + 1 - list_cursor] }) + else + for i = 1, #list_highlight_cursor do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor then + table.remove(list_highlight_cursor, i) + end + end + if pos > -1 then + for i = 1, #list_highlight_cursor do + for j = pos, 1, -1 do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor - j then + table.remove(list_highlight_cursor, i) + end + end + end + else + for i = #list_highlight_cursor, 1, -1 do + for j = pos, -1, 1 do + if list_highlight_cursor[i] and list_highlight_cursor[i][1] == list_cursor - j then + table.remove(list_highlight_cursor, i) + end + end + end + end + end + end + end + + if o.loop_through_list then + if list_cursor_temp > #list_contents then + list_cursor = 1 + elseif list_cursor_temp < 1 then + list_cursor = #list_contents + end + end + + draw_list() +end + +function list_move_up(action) + select(-1, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_down(action) + select(1, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_first(action) + select(1 - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_move_last(action) + select(#list_contents - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_page_up(action) + select(list_start + 1 - list_cursor, action) + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_page_down(action) + if o.list_middle_loader then + if #list_contents < o.list_show_amount then + select(#list_contents - list_cursor, action) + else + select(o.list_show_amount + list_start - list_cursor, action) + end + else + if o.list_show_amount > list_cursor then + select(o.list_show_amount - list_cursor, action) + elseif #list_contents - list_cursor >= o.list_show_amount then + select(o.list_show_amount, action) + else + select(#list_contents - list_cursor, action) + end + end + + if search_active and o.search_not_typing_smartly then + list_search_not_typing_mode(true) + end +end + +function list_highlight_all() + get_list_contents(filterName) + if not list_contents or not list_contents[1] then + return + end + + if #list_highlight_cursor < #list_contents then + for i = 1, #list_contents do + if not has_value(list_highlight_cursor, i, 1) then + table.insert(list_highlight_cursor, { i, list_contents[#list_contents + 1 - i] }) + end + end + select(0) + else + list_unhighlight_all() + end +end + +function list_unhighlight_all() + if not list_highlight_cursor or not list_highlight_cursor[1] then + return + end + list_highlight_cursor = {} + select(0) +end +--End of LogManager Navigation-- + +--LogManager Actions-- +function load(list_cursor, add_playlist, target_time) + if not list_contents or not list_contents[1] then + return + end + if not target_time then + seekTime = tonumber(list_contents[#list_contents - list_cursor + 1].found_time) + o.resume_offset + if seekTime < 0 then + seekTime = 0 + end + else + seekTime = target_time + end + if + file_exists(list_contents[#list_contents - list_cursor + 1].found_path) + or starts_protocol(protocols, list_contents[#list_contents - list_cursor + 1].found_path) + then + if not add_playlist then + if filePath ~= list_contents[#list_contents - list_cursor + 1].found_path then + mp.commandv("loadfile", list_contents[#list_contents - list_cursor + 1].found_path) + resume_selected = true + else + mp.commandv("seek", seekTime, "absolute", "exact") + list_close_and_trash_collection() + end + if o.osd_messages == true then + mp.osd_message( + "Loaded:\n" + .. list_contents[#list_contents - list_cursor + 1].found_name + .. o.time_seperator + .. format_time(seekTime, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info( + "Loaded the below file:\n" + .. list_contents[#list_contents - list_cursor + 1].found_name + .. " | " + .. format_time(seekTime) + ) + else + mp.commandv("loadfile", list_contents[#list_contents - list_cursor + 1].found_path, "append-play") + if o.osd_messages == true then + mp.osd_message( + "Added into Playlist:\n" .. list_contents[#list_contents - list_cursor + 1].found_name .. " " + ) + end + msg.info( + "Added the below file into playlist:\n" .. list_contents[#list_contents - list_cursor + 1].found_path + ) + end + else + if o.osd_messages == true then + mp.osd_message("File Doesn't Exist:\n" .. list_contents[#list_contents - list_cursor + 1].found_path) + end + msg.info( + "The file below doesn't seem to exist:\n" .. list_contents[#list_contents - list_cursor + 1].found_path + ) + return + end +end + +function list_select() + load(list_cursor) +end + +function list_add_playlist(action) + if not action then + load(list_cursor, true) + elseif action == "highlight" then + if not list_highlight_cursor or not list_highlight_cursor[1] then + return + end + local file_ignored_total = 0 + + for i = 1, #list_highlight_cursor do + if + file_exists(list_highlight_cursor[i][2].found_path) + or starts_protocol(protocols, list_highlight_cursor[i][2].found_path) + then + mp.commandv("loadfile", list_highlight_cursor[i][2].found_path, "append-play") + else + msg.warn( + "The below file was not added into playlist as it does not seem to exist:\n" + .. list_highlight_cursor[i][2].found_path + ) + file_ignored_total = file_ignored_total + 1 + end + end + if o.osd_messages == true then + if file_ignored_total > 0 then + mp.osd_message( + "Added into Playlist " + .. #list_highlight_cursor - file_ignored_total + .. " Item/s\nIgnored " + .. file_ignored_total + .. " Item/s That Do Not Exist" + ) + else + mp.osd_message("Added into Playlist " .. #list_highlight_cursor - file_ignored_total .. " Item/s") + end + end + if file_ignored_total > 0 then + msg.warn("Ignored a total of " .. file_ignored_total .. " Item/s that does not seem to exist") + end + msg.info("Added into playlist a total of " .. #list_highlight_cursor - file_ignored_total .. " item/s") + end +end + +function delete_log_entry_specific(target_index, target_path, target_time) + local trigger_delete = false + list_contents = read_log_table() + if not list_contents or not list_contents[1] then + return + end + if target_index == "last" then + target_index = #list_contents + end + if not target_index then + return + end + + if target_index and target_path and target_time then + if + list_contents[target_index].found_path == target_path + and tonumber(list_contents[target_index].found_time) == target_time + then + table.remove(list_contents, target_index) + trigger_delete = true + end + elseif target_index and target_path and not target_time then + if list_contents[target_index].found_path == target_path then + table.remove(list_contents, target_index) + trigger_delete = true + end + elseif target_index and target_time and not target_path then + if tonumber(list_contents[target_index].found_time) == target_time then + table.remove(list_contents, target_index) + trigger_delete = true + end + elseif target_index and not target_path and not target_time then + table.remove(list_contents, target_index) + trigger_delete = true + end + + if not trigger_delete then + return + end + local f = io.open(log_fullpath, "w+") + if list_contents ~= nil and list_contents[1] then + for i = 1, #list_contents do + f:write(("%s\n"):format(list_contents[i].found_line)) + end + end + f:close() +end + +function delete_log_entry(multiple, round, target_path, target_time, entry_limit) + if not target_path then + target_path = filePath + end + if not target_time then + target_time = seekTime + end + list_contents = read_log_table() + if not list_contents or not list_contents[1] then + return + end + local trigger_delete = false + + if not multiple then + for i = #list_contents, 1, -1 do + if not round then + if + list_contents[i].found_path == target_path + and tonumber(list_contents[i].found_time) == target_time + then + table.remove(list_contents, i) + trigger_delete = true + break + end + else + if + list_contents[i].found_path == target_path + and math.floor(tonumber(list_contents[i].found_time)) == target_time + then + table.remove(list_contents, i) + trigger_delete = true + break + end + end + end + else + for i = #list_contents, 1, -1 do + if not round then + if + list_contents[i].found_path == target_path + and tonumber(list_contents[i].found_time) == target_time + then + table.remove(list_contents, i) + trigger_delete = true + end + else + if + list_contents[i].found_path == target_path + and math.floor(tonumber(list_contents[i].found_time)) == target_time + then + table.remove(list_contents, i) + trigger_delete = true + end + end + end + end + + if entry_limit and entry_limit > -1 then + local entries_found = 0 + for i = #list_contents, 1, -1 do + if list_contents[i].found_path == target_path and entries_found < entry_limit then + entries_found = entries_found + 1 + elseif list_contents[i].found_path == target_path and entries_found >= entry_limit then + table.remove(list_contents, i) + trigger_delete = true + end + end + end + + if not trigger_delete then + return + end + local f = io.open(log_fullpath, "w+") + if list_contents ~= nil and list_contents[1] then + for i = 1, #list_contents do + f:write(("%s\n"):format(list_contents[i].found_line)) + end + end + f:close() +end + +function delete_log_entry_highlighted() + if not list_highlight_cursor or not list_highlight_cursor[1] then + return + end + list_contents = read_log_table() + if not list_contents or not list_contents[1] then + return + end + + local list_contents_length = #list_contents + + for i = 1, list_contents_length do + for j = 1, #list_highlight_cursor do + if list_contents[list_contents_length + 1 - i] then + if + list_contents[list_contents_length + 1 - i].found_sequence + == list_highlight_cursor[j][2].found_sequence + then + table.remove(list_contents, list_contents_length + 1 - i) + end + end + end + end + + msg.info("Deleted " .. #list_highlight_cursor .. " Item/s") + + list_unhighlight_all() + + local f = io.open(log_fullpath, "w+") + if list_contents ~= nil and list_contents[1] then + for i = 1, #list_contents do + f:write(("%s\n"):format(list_contents[i].found_line)) + end + end + f:close() +end + +function delete_selected() + filePath = list_contents[#list_contents - list_cursor + 1].found_path + fileTitle = list_contents[#list_contents - list_cursor + 1].found_name + seekTime = tonumber(list_contents[#list_contents - list_cursor + 1].found_time) + if not filePath and not seekTime then + msg.info("Failed to delete") + return + end + delete_log_entry() + msg.info('Deleted "' .. filePath .. '" | ' .. format_time(seekTime)) + filePath, fileTitle, fileLength = get_file() +end + +function list_delete(action) + if not action then + delete_selected() + elseif action == "highlight" then + delete_log_entry_highlighted() + end + get_list_contents() + if not list_contents or not list_contents[1] then + list_close_and_trash_collection() + return + end + if list_cursor < #list_contents + 1 then + select(0) + else + list_move_last() + end +end + +function get_total_duration(action) + if not list_contents or not list_contents[1] then + return 0 + end + local list_total_duration = 0 + if action == "found_time" or action == "found_length" or action == "found_remaining" then + for i = #list_contents, 1, -1 do + if tonumber(list_contents[i][action]) > 0 then + list_total_duration = list_total_duration + list_contents[i][action] + end + end + end + return list_total_duration +end + +function list_cycle_sort() + local next_sort + for i = 1, #available_sorts do + if sortName == available_sorts[i] then + if i == #available_sorts then + next_sort = available_sorts[1] + break + else + next_sort = available_sorts[i + 1] + break + end + end + end + if not next_sort then + return + end + get_list_contents(filterName, next_sort) + sortName = next_sort + update_list_highlist_cursor() + select(0) +end + +function update_list_highlist_cursor() + if not list_highlight_cursor or not list_highlight_cursor[1] then + return + end + + local temp_list_highlight_cursor = {} + for i = 1, #list_contents do + for j = 1, #list_highlight_cursor do + if list_contents[#list_contents + 1 - i].found_sequence == list_highlight_cursor[j][2].found_sequence then + table.insert(temp_list_highlight_cursor, { i, list_highlight_cursor[j][2] }) + end + end + end + + list_highlight_cursor = temp_list_highlight_cursor +end + +--End of LogManager Actions-- + +--LogManager Filter Functions-- +function get_page_properties(filter) + if not filter then + return + end + for i = 1, #list_pages do + if list_pages[i][1] == filter then + list_cursor = list_pages[i][2] + list_highlight_cursor = list_pages[i][4] + sortName = list_pages[i][5] + end + end + if list_cursor > #list_contents then + list_move_last() + end +end + +function select_filter_sequence(pos) + if not list_drawn then + return + end + local curr_pos + local target_pos + + for i = 1, #o.filters_and_sequence do + if filterName == o.filters_and_sequence[i] then + curr_pos = i + end + end + + if curr_pos and pos > -1 then + for i = curr_pos, #o.filters_and_sequence do + if o.filters_and_sequence[i + pos] then + get_list_contents(o.filters_and_sequence[i + pos]) + if list_contents ~= nil and list_contents[1] then + target_pos = i + pos + break + end + end + end + elseif curr_pos and pos < 0 then + for i = curr_pos, 0, -1 do + if o.filters_and_sequence[i + pos] then + get_list_contents(o.filters_and_sequence[i + pos]) + if list_contents ~= nil and list_contents[1] then + target_pos = i + pos + break + end + end + end + end + + if o.loop_through_filters then + if not target_pos and pos > -1 or target_pos and target_pos > #o.filters_and_sequence then + for i = 1, #o.filters_and_sequence do + get_list_contents(o.filters_and_sequence[i]) + if list_contents ~= nil and list_contents[1] then + target_pos = i + break + end + end + end + if not target_pos and pos < 0 or target_pos and target_pos < 1 then + for i = #o.filters_and_sequence, 1, -1 do + get_list_contents(o.filters_and_sequence[i]) + if list_contents ~= nil and list_contents[1] then + target_pos = i + break + end + end + end + end + + if o.filters_and_sequence[target_pos] then + display_list(o.filters_and_sequence[target_pos], nil, "hide-osd") + end +end + +function list_filter_next() + select_filter_sequence(1) +end +function list_filter_previous() + select_filter_sequence(-1) +end +--End of LogManager Filter Functions-- + +--LogManager (List Bind and Unbind)-- +function get_list_keybinds() + bind_keys(o.list_ignored_keybind, "ignore") + bind_keys(o.list_move_up_keybind, "move-up", list_move_up, "repeatable") + bind_keys(o.list_move_down_keybind, "move-down", list_move_down, "repeatable") + bind_keys(o.list_move_first_keybind, "move-first", list_move_first, "repeatable") + bind_keys(o.list_move_last_keybind, "move-last", list_move_last, "repeatable") + bind_keys(o.list_page_up_keybind, "page-up", list_page_up, "repeatable") + bind_keys(o.list_page_down_keybind, "page-down", list_page_down, "repeatable") + bind_keys(o.list_select_keybind, "list-select", list_select) + bind_keys(o.list_add_playlist_keybind, "list-add-playlist", list_add_playlist) + bind_keys(o.list_add_playlist_highlighted_keybind, "list-add-playlist-highlight", function() + list_add_playlist("highlight") + end) + bind_keys(o.list_delete_keybind, "list-delete", list_delete) + bind_keys(o.list_delete_highlighted_keybind, "list-delete-highlight", function() + list_delete("highlight") + end) + bind_keys(o.next_filter_sequence_keybind, "list-filter-next", list_filter_next) + bind_keys(o.previous_filter_sequence_keybind, "list-filter-previous", list_filter_previous) + bind_keys(o.list_search_activate_keybind, "list-search-activate", list_search_activate) + bind_keys(o.list_highlight_all_keybind, "list-highlight-all", list_highlight_all) + bind_keys(o.list_unhighlight_all_keybind, "list-unhighlight-all", list_unhighlight_all) + bind_keys(o.list_cycle_sort_keybind, "list-cycle-sort", list_cycle_sort) + + for i = 1, #o.list_highlight_move_keybind do + for j = 1, #o.list_move_up_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_move_up_keybind[j], + "highlight-move-up" .. j, + function() + list_move_up("highlight") + end, + "repeatable" + ) + end + for j = 1, #o.list_move_down_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_move_down_keybind[j], + "highlight-move-down" .. j, + function() + list_move_down("highlight") + end, + "repeatable" + ) + end + for j = 1, #o.list_move_first_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_move_first_keybind[j], + "highlight-move-first" .. j, + function() + list_move_first("highlight") + end, + "repeatable" + ) + end + for j = 1, #o.list_move_last_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_move_last_keybind[j], + "highlight-move-last" .. j, + function() + list_move_last("highlight") + end, + "repeatable" + ) + end + for j = 1, #o.list_page_up_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_page_up_keybind[j], + "highlight-page-up" .. j, + function() + list_page_up("highlight") + end, + "repeatable" + ) + end + for j = 1, #o.list_page_down_keybind do + mp.add_forced_key_binding( + o.list_highlight_move_keybind[i] .. "+" .. o.list_page_down_keybind[j], + "highlight-page-down" .. j, + function() + list_page_down("highlight") + end, + "repeatable" + ) + end + end + + if not search_active then + bind_keys(o.list_close_keybind, "list-close", list_close_and_trash_collection) + end + + for i = 1, #o.list_filter_jump_keybind do + mp.add_forced_key_binding(o.list_filter_jump_keybind[i][1], "list-filter-jump" .. i, function() + display_list(o.list_filter_jump_keybind[i][2]) + end) + end + + for i = 1, #o.open_list_keybind do + if i == 1 then + mp.remove_key_binding("open-list") + else + mp.remove_key_binding("open-list" .. i) + end + end + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 then + mp.add_forced_key_binding("1", "recent-1", function() + load(list_start + 1) + end) + mp.add_forced_key_binding("2", "recent-2", function() + load(list_start + 2) + end) + mp.add_forced_key_binding("3", "recent-3", function() + load(list_start + 3) + end) + mp.add_forced_key_binding("4", "recent-4", function() + load(list_start + 4) + end) + mp.add_forced_key_binding("5", "recent-5", function() + load(list_start + 5) + end) + mp.add_forced_key_binding("6", "recent-6", function() + load(list_start + 6) + end) + mp.add_forced_key_binding("7", "recent-7", function() + load(list_start + 7) + end) + mp.add_forced_key_binding("8", "recent-8", function() + load(list_start + 8) + end) + mp.add_forced_key_binding("9", "recent-9", function() + load(list_start + 9) + end) + mp.add_forced_key_binding("0", "recent-0", function() + load(list_start + 10) + end) + end +end + +function unbind_list_keys() + unbind_keys(o.list_ignored_keybind, "ignore") + unbind_keys(o.list_move_up_keybind, "move-up") + unbind_keys(o.list_move_down_keybind, "move-down") + unbind_keys(o.list_move_first_keybind, "move-first") + unbind_keys(o.list_move_last_keybind, "move-last") + unbind_keys(o.list_page_up_keybind, "page-up") + unbind_keys(o.list_page_down_keybind, "page-down") + unbind_keys(o.list_select_keybind, "list-select") + unbind_keys(o.list_add_playlist_keybind, "list-add-playlist") + unbind_keys(o.list_add_playlist_highlighted_keybind, "list-add-playlist-highlight") + unbind_keys(o.list_delete_keybind, "list-delete") + unbind_keys(o.list_delete_highlighted_keybind, "list-delete-highlight") + unbind_keys(o.list_close_keybind, "list-close") + unbind_keys(o.next_filter_sequence_keybind, "list-filter-next") + unbind_keys(o.previous_filter_sequence_keybind, "list-filter-previous") + unbind_keys(o.list_highlight_all_keybind, "list-highlight-all") + unbind_keys(o.list_highlight_all_keybind, "list-unhighlight-all") + unbind_keys(o.list_cycle_sort_keybind, "list-cycle-sort") + + for i = 1, #o.list_move_up_keybind do + mp.remove_key_binding("highlight-move-up" .. i) + end + for i = 1, #o.list_move_down_keybind do + mp.remove_key_binding("highlight-move-down" .. i) + end + for i = 1, #o.list_move_first_keybind do + mp.remove_key_binding("highlight-move-first" .. i) + end + for i = 1, #o.list_move_last_keybind do + mp.remove_key_binding("highlight-move-last" .. i) + end + for i = 1, #o.list_page_up_keybind do + mp.remove_key_binding("highlight-page-up" .. i) + end + for i = 1, #o.list_page_down_keybind do + mp.remove_key_binding("highlight-page-down" .. i) + end + + for i = 1, #o.list_filter_jump_keybind do + mp.remove_key_binding("list-filter-jump" .. i) + end + + for i = 1, #o.open_list_keybind do + if i == 1 then + mp.add_forced_key_binding(o.open_list_keybind[i][1], "open-list", function() + display_list(o.open_list_keybind[i][2]) + end) + else + mp.add_forced_key_binding(o.open_list_keybind[i][1], "open-list" .. i, function() + display_list(o.open_list_keybind[i][2]) + end) + end + end + + if o.quickselect_0to9_keybind and o.list_show_amount <= 10 then + mp.remove_key_binding("recent-1") + mp.remove_key_binding("recent-2") + mp.remove_key_binding("recent-3") + mp.remove_key_binding("recent-4") + mp.remove_key_binding("recent-5") + mp.remove_key_binding("recent-6") + mp.remove_key_binding("recent-7") + mp.remove_key_binding("recent-8") + mp.remove_key_binding("recent-9") + mp.remove_key_binding("recent-0") + end +end + +function list_close_and_trash_collection() + if utils.shared_script_property_set then + utils.shared_script_property_set("smartcopypaste-menu-open", "no") + end + mp.set_property("user-data/smartcopypaste/menu-open", "no") + if o.toggle_idlescreen then + mp.commandv("script-message", "osc-idlescreen", "yes", "no_osd") + end + unbind_list_keys() + unbind_search_keys() + mp.set_osd_ass(0, 0, "") + list_drawn = false + list_cursor = 1 + list_start = 0 + filterName = "all" + list_pages = {} + search_string = "" + search_active = false + list_highlight_cursor = {} + sortName = nil +end +--End of LogManager (List Bind and Unbind)-- + +--LogManager Search Feature-- +function list_search_exit() + search_active = false + get_list_contents(filterName) + get_page_properties(filterName) + select(0) + unbind_search_keys() + get_list_keybinds() +end + +function list_search_not_typing_mode(auto_triggered) + if auto_triggered then + if search_string ~= "" and list_contents[1] then + search_active = "not_typing" + elseif not list_contents[1] then + return + else + search_active = false + end + else + if search_string ~= "" then + search_active = "not_typing" + else + search_active = false + end + end + select(0) + unbind_search_keys() + get_list_keybinds() +end + +function list_search_activate() + if not list_drawn then + return + end + if search_active == "typing" then + list_search_exit() + return + end + search_active = "typing" + + for i = 1, #list_pages do + if list_pages[i][1] == filterName then + list_pages[i][2] = list_cursor + list_pages[i][4] = list_highlight_cursor + list_pages[i][5] = sortName + end + end + + update_search_results("", "") + bind_search_keys() +end + +function update_search_results(character, action) + if not character then + character = "" + end + if action == "string_del" then + search_string = search_string:sub(1, -2) + end + search_string = search_string .. character + local prev_contents_length = #list_contents + get_list_contents(filterName) + + if prev_contents_length ~= #list_contents then + list_highlight_cursor = {} + end + + if character ~= "" and #list_contents > 0 or action ~= nil and #list_contents > 0 then + select(1 - list_cursor) + elseif #list_contents == 0 then + list_cursor = 0 + select(list_cursor) + else + select(0) + end +end + +function bind_search_keys() + mp.add_forced_key_binding("a", "search_string_a", function() + update_search_results("a") + end, "repeatable") + mp.add_forced_key_binding("b", "search_string_b", function() + update_search_results("b") + end, "repeatable") + mp.add_forced_key_binding("c", "search_string_c", function() + update_search_results("c") + end, "repeatable") + mp.add_forced_key_binding("d", "search_string_d", function() + update_search_results("d") + end, "repeatable") + mp.add_forced_key_binding("e", "search_string_e", function() + update_search_results("e") + end, "repeatable") + mp.add_forced_key_binding("f", "search_string_f", function() + update_search_results("f") + end, "repeatable") + mp.add_forced_key_binding("g", "search_string_g", function() + update_search_results("g") + end, "repeatable") + mp.add_forced_key_binding("h", "search_string_h", function() + update_search_results("h") + end, "repeatable") + mp.add_forced_key_binding("i", "search_string_i", function() + update_search_results("i") + end, "repeatable") + mp.add_forced_key_binding("j", "search_string_j", function() + update_search_results("j") + end, "repeatable") + mp.add_forced_key_binding("k", "search_string_k", function() + update_search_results("k") + end, "repeatable") + mp.add_forced_key_binding("l", "search_string_l", function() + update_search_results("l") + end, "repeatable") + mp.add_forced_key_binding("m", "search_string_m", function() + update_search_results("m") + end, "repeatable") + mp.add_forced_key_binding("n", "search_string_n", function() + update_search_results("n") + end, "repeatable") + mp.add_forced_key_binding("o", "search_string_o", function() + update_search_results("o") + end, "repeatable") + mp.add_forced_key_binding("p", "search_string_p", function() + update_search_results("p") + end, "repeatable") + mp.add_forced_key_binding("q", "search_string_q", function() + update_search_results("q") + end, "repeatable") + mp.add_forced_key_binding("r", "search_string_r", function() + update_search_results("r") + end, "repeatable") + mp.add_forced_key_binding("s", "search_string_s", function() + update_search_results("s") + end, "repeatable") + mp.add_forced_key_binding("t", "search_string_t", function() + update_search_results("t") + end, "repeatable") + mp.add_forced_key_binding("u", "search_string_u", function() + update_search_results("u") + end, "repeatable") + mp.add_forced_key_binding("v", "search_string_v", function() + update_search_results("v") + end, "repeatable") + mp.add_forced_key_binding("w", "search_string_w", function() + update_search_results("w") + end, "repeatable") + mp.add_forced_key_binding("x", "search_string_x", function() + update_search_results("x") + end, "repeatable") + mp.add_forced_key_binding("y", "search_string_y", function() + update_search_results("y") + end, "repeatable") + mp.add_forced_key_binding("z", "search_string_z", function() + update_search_results("z") + end, "repeatable") + + mp.add_forced_key_binding("A", "search_string_A", function() + update_search_results("A") + end, "repeatable") + mp.add_forced_key_binding("B", "search_string_B", function() + update_search_results("B") + end, "repeatable") + mp.add_forced_key_binding("C", "search_string_C", function() + update_search_results("C") + end, "repeatable") + mp.add_forced_key_binding("D", "search_string_D", function() + update_search_results("D") + end, "repeatable") + mp.add_forced_key_binding("E", "search_string_E", function() + update_search_results("E") + end, "repeatable") + mp.add_forced_key_binding("F", "search_string_F", function() + update_search_results("F") + end, "repeatable") + mp.add_forced_key_binding("G", "search_string_G", function() + update_search_results("G") + end, "repeatable") + mp.add_forced_key_binding("H", "search_string_H", function() + update_search_results("H") + end, "repeatable") + mp.add_forced_key_binding("I", "search_string_I", function() + update_search_results("I") + end, "repeatable") + mp.add_forced_key_binding("J", "search_string_J", function() + update_search_results("J") + end, "repeatable") + mp.add_forced_key_binding("K", "search_string_K", function() + update_search_results("K") + end, "repeatable") + mp.add_forced_key_binding("L", "search_string_L", function() + update_search_results("L") + end, "repeatable") + mp.add_forced_key_binding("M", "search_string_M", function() + update_search_results("M") + end, "repeatable") + mp.add_forced_key_binding("N", "search_string_N", function() + update_search_results("N") + end, "repeatable") + mp.add_forced_key_binding("O", "search_string_O", function() + update_search_results("O") + end, "repeatable") + mp.add_forced_key_binding("P", "search_string_P", function() + update_search_results("P") + end, "repeatable") + mp.add_forced_key_binding("Q", "search_string_Q", function() + update_search_results("Q") + end, "repeatable") + mp.add_forced_key_binding("R", "search_string_R", function() + update_search_results("R") + end, "repeatable") + mp.add_forced_key_binding("S", "search_string_S", function() + update_search_results("S") + end, "repeatable") + mp.add_forced_key_binding("T", "search_string_T", function() + update_search_results("T") + end, "repeatable") + mp.add_forced_key_binding("U", "search_string_U", function() + update_search_results("U") + end, "repeatable") + mp.add_forced_key_binding("V", "search_string_V", function() + update_search_results("V") + end, "repeatable") + mp.add_forced_key_binding("W", "search_string_W", function() + update_search_results("W") + end, "repeatable") + mp.add_forced_key_binding("X", "search_string_X", function() + update_search_results("X") + end, "repeatable") + mp.add_forced_key_binding("Y", "search_string_Y", function() + update_search_results("Y") + end, "repeatable") + mp.add_forced_key_binding("Z", "search_string_Z", function() + update_search_results("Z") + end, "repeatable") + + mp.add_forced_key_binding("1", "search_string_1", function() + update_search_results("1") + end, "repeatable") + mp.add_forced_key_binding("2", "search_string_2", function() + update_search_results("2") + end, "repeatable") + mp.add_forced_key_binding("3", "search_string_3", function() + update_search_results("3") + end, "repeatable") + mp.add_forced_key_binding("4", "search_string_4", function() + update_search_results("4") + end, "repeatable") + mp.add_forced_key_binding("5", "search_string_5", function() + update_search_results("5") + end, "repeatable") + mp.add_forced_key_binding("6", "search_string_6", function() + update_search_results("6") + end, "repeatable") + mp.add_forced_key_binding("7", "search_string_7", function() + update_search_results("7") + end, "repeatable") + mp.add_forced_key_binding("8", "search_string_8", function() + update_search_results("8") + end, "repeatable") + mp.add_forced_key_binding("9", "search_string_9", function() + update_search_results("9") + end, "repeatable") + mp.add_forced_key_binding("0", "search_string_0", function() + update_search_results("0") + end, "repeatable") + + mp.add_forced_key_binding("SPACE", "search_string_space", function() + update_search_results(" ") + end, "repeatable") + mp.add_forced_key_binding("`", "search_string_`", function() + update_search_results("`") + end, "repeatable") + mp.add_forced_key_binding("~", "search_string_~", function() + update_search_results("~") + end, "repeatable") + mp.add_forced_key_binding("!", "search_string_!", function() + update_search_results("!") + end, "repeatable") + mp.add_forced_key_binding("@", "search_string_@", function() + update_search_results("@") + end, "repeatable") + mp.add_forced_key_binding("SHARP", "search_string_sharp", function() + update_search_results("#") + end, "repeatable") + mp.add_forced_key_binding("$", "search_string_$", function() + update_search_results("$") + end, "repeatable") + mp.add_forced_key_binding("%", "search_string_percentage", function() + update_search_results("%") + end, "repeatable") + mp.add_forced_key_binding("^", "search_string_^", function() + update_search_results("^") + end, "repeatable") + mp.add_forced_key_binding("&", "search_string_&", function() + update_search_results("&") + end, "repeatable") + mp.add_forced_key_binding("*", "search_string_*", function() + update_search_results("*") + end, "repeatable") + mp.add_forced_key_binding("(", "search_string_(", function() + update_search_results("(") + end, "repeatable") + mp.add_forced_key_binding(")", "search_string_)", function() + update_search_results(")") + end, "repeatable") + mp.add_forced_key_binding("-", "search_string_-", function() + update_search_results("-") + end, "repeatable") + mp.add_forced_key_binding("_", "search_string__", function() + update_search_results("_") + end, "repeatable") + mp.add_forced_key_binding("=", "search_string_=", function() + update_search_results("=") + end, "repeatable") + mp.add_forced_key_binding("+", "search_string_+", function() + update_search_results("+") + end, "repeatable") + mp.add_forced_key_binding("\\", "search_string_\\", function() + update_search_results("\\") + end, "repeatable") + mp.add_forced_key_binding("|", "search_string_|", function() + update_search_results("|") + end, "repeatable") + mp.add_forced_key_binding("]", "search_string_]", function() + update_search_results("]") + end, "repeatable") + mp.add_forced_key_binding("}", "search_string_rightcurly", function() + update_search_results("}") + end, "repeatable") + mp.add_forced_key_binding("[", "search_string_[", function() + update_search_results("[") + end, "repeatable") + mp.add_forced_key_binding("{", "search_string_leftcurly", function() + update_search_results("{") + end, "repeatable") + mp.add_forced_key_binding("'", "search_string_'", function() + update_search_results("'") + end, "repeatable") + mp.add_forced_key_binding('"', 'search_string_"', function() + update_search_results('"') + end, "repeatable") + mp.add_forced_key_binding(";", "search_string_semicolon", function() + update_search_results(";") + end, "repeatable") + mp.add_forced_key_binding(":", "search_string_:", function() + update_search_results(":") + end, "repeatable") + mp.add_forced_key_binding("/", "search_string_/", function() + update_search_results("/") + end, "repeatable") + mp.add_forced_key_binding("?", "search_string_?", function() + update_search_results("?") + end, "repeatable") + mp.add_forced_key_binding(".", "search_string_.", function() + update_search_results(".") + end, "repeatable") + mp.add_forced_key_binding(">", "search_string_>", function() + update_search_results(">") + end, "repeatable") + mp.add_forced_key_binding(",", "search_string_,", function() + update_search_results(",") + end, "repeatable") + mp.add_forced_key_binding("<", "search_string_<", function() + update_search_results("<") + end, "repeatable") + + mp.add_forced_key_binding("bs", "search_string_del", function() + update_search_results("", "string_del") + end, "repeatable") + bind_keys(o.list_close_keybind, "search_exit", function() + list_search_exit() + end) + bind_keys(o.list_search_not_typing_mode_keybind, "search_string_not_typing", function() + list_search_not_typing_mode(false) + end) + + if o.search_not_typing_smartly then + bind_keys(o.next_filter_sequence_keybind, "list-filter-next", function() + list_filter_next() + list_search_not_typing_mode(true) + end) + bind_keys(o.previous_filter_sequence_keybind, "list-filter-previous", function() + list_filter_previous() + list_search_not_typing_mode(true) + end) + bind_keys(o.list_delete_keybind, "list-delete", function() + list_delete() + list_search_not_typing_mode(true) + end) + bind_keys(o.list_delete_highlighted_keybind, "list-delete-highlight", function() + list_delete("highlight") + list_search_not_typing_mode(true) + end) + end +end + +function unbind_search_keys() + mp.remove_key_binding("search_string_a") + mp.remove_key_binding("search_string_b") + mp.remove_key_binding("search_string_c") + mp.remove_key_binding("search_string_d") + mp.remove_key_binding("search_string_e") + mp.remove_key_binding("search_string_f") + mp.remove_key_binding("search_string_g") + mp.remove_key_binding("search_string_h") + mp.remove_key_binding("search_string_i") + mp.remove_key_binding("search_string_j") + mp.remove_key_binding("search_string_k") + mp.remove_key_binding("search_string_l") + mp.remove_key_binding("search_string_m") + mp.remove_key_binding("search_string_n") + mp.remove_key_binding("search_string_o") + mp.remove_key_binding("search_string_p") + mp.remove_key_binding("search_string_q") + mp.remove_key_binding("search_string_r") + mp.remove_key_binding("search_string_s") + mp.remove_key_binding("search_string_t") + mp.remove_key_binding("search_string_u") + mp.remove_key_binding("search_string_v") + mp.remove_key_binding("search_string_w") + mp.remove_key_binding("search_string_x") + mp.remove_key_binding("search_string_y") + mp.remove_key_binding("search_string_z") + + mp.remove_key_binding("search_string_A") + mp.remove_key_binding("search_string_B") + mp.remove_key_binding("search_string_C") + mp.remove_key_binding("search_string_D") + mp.remove_key_binding("search_string_E") + mp.remove_key_binding("search_string_F") + mp.remove_key_binding("search_string_G") + mp.remove_key_binding("search_string_H") + mp.remove_key_binding("search_string_I") + mp.remove_key_binding("search_string_J") + mp.remove_key_binding("search_string_K") + mp.remove_key_binding("search_string_L") + mp.remove_key_binding("search_string_M") + mp.remove_key_binding("search_string_N") + mp.remove_key_binding("search_string_O") + mp.remove_key_binding("search_string_P") + mp.remove_key_binding("search_string_Q") + mp.remove_key_binding("search_string_R") + mp.remove_key_binding("search_string_S") + mp.remove_key_binding("search_string_T") + mp.remove_key_binding("search_string_U") + mp.remove_key_binding("search_string_V") + mp.remove_key_binding("search_string_W") + mp.remove_key_binding("search_string_X") + mp.remove_key_binding("search_string_Y") + mp.remove_key_binding("search_string_Z") + + mp.remove_key_binding("search_string_1") + mp.remove_key_binding("search_string_2") + mp.remove_key_binding("search_string_3") + mp.remove_key_binding("search_string_4") + mp.remove_key_binding("search_string_5") + mp.remove_key_binding("search_string_6") + mp.remove_key_binding("search_string_7") + mp.remove_key_binding("search_string_8") + mp.remove_key_binding("search_string_9") + mp.remove_key_binding("search_string_0") + + mp.remove_key_binding("search_string_space") + mp.remove_key_binding("search_string_`") + mp.remove_key_binding("search_string_~") + mp.remove_key_binding("search_string_!") + mp.remove_key_binding("search_string_@") + mp.remove_key_binding("search_string_sharp") + mp.remove_key_binding("search_string_$") + mp.remove_key_binding("search_string_percentage") + mp.remove_key_binding("search_string_^") + mp.remove_key_binding("search_string_&") + mp.remove_key_binding("search_string_*") + mp.remove_key_binding("search_string_(") + mp.remove_key_binding("search_string_)") + mp.remove_key_binding("search_string_-") + mp.remove_key_binding("search_string__") + mp.remove_key_binding("search_string_=") + mp.remove_key_binding("search_string_+") + mp.remove_key_binding("search_string_\\") + mp.remove_key_binding("search_string_|") + mp.remove_key_binding("search_string_]") + mp.remove_key_binding("search_string_rightcurly") + mp.remove_key_binding("search_string_[") + mp.remove_key_binding("search_string_leftcurly") + mp.remove_key_binding("search_string_'") + mp.remove_key_binding('search_string_"') + mp.remove_key_binding("search_string_semicolon") + mp.remove_key_binding("search_string_:") + mp.remove_key_binding("search_string_/") + mp.remove_key_binding("search_string_?") + mp.remove_key_binding("search_string_.") + mp.remove_key_binding("search_string_>") + mp.remove_key_binding("search_string_,") + mp.remove_key_binding("search_string_<") + + mp.remove_key_binding("search_string_del") + if not search_active then + unbind_keys(o.list_close_keybind, "search_exit") + end +end +--End of LogManager Search Feature-- +---------End of LogManager--------- + +function mark_chapter() + if not o.mark_clipboard_as_chapter then + return + end + + local all_chapters = mp.get_property_native("chapter-list") + local chapter_index = 0 + local chapters_time = {} + + get_list_contents() + if not list_contents or not list_contents[1] then + return + end + for i = 1, #list_contents do + if list_contents[i].found_path == filePath and tonumber(list_contents[i].found_time) > 0 then + table.insert(chapters_time, tonumber(list_contents[i].found_time)) + end + end + if not chapters_time[1] then + return + end + + table.sort(chapters_time, function(a, b) + return a < b + end) + + for i = 1, #chapters_time do + chapter_index = chapter_index + 1 + + all_chapters[chapter_index] = { + title = "SmartCopyPaste-II " .. chapter_index, + time = chapters_time[i], + } + end + + table.sort(all_chapters, function(a, b) + return a["time"] < b["time"] + end) + + mp.set_property_native("chapter-list", all_chapters) +end + +function write_log(target_time, update_seekTime, entry_limit, action) + if not filePath then + return + end + local prev_seekTime = seekTime + seekTime = (mp.get_property_number("time-pos") or 0) + if target_time then + seekTime = target_time + end + if seekTime < 0 then + seekTime = 0 + end + + delete_log_entry(false, true, filePath, math.floor(seekTime), entry_limit) + + local f = io.open(log_fullpath, "a+") + if o.file_title_logging == "all" then + f:write( + ('[%s] "%s" | %s | %s | %s'):format( + os.date(o.date_format), + fileTitle, + filePath, + log_length_text .. tostring(fileLength), + log_time_text .. tostring(seekTime) + ) + ) + elseif o.file_title_logging == "protocols" and (starts_protocol(o.logging_protocols, filePath)) then + f:write( + ('[%s] "%s" | %s | %s | %s'):format( + os.date(o.date_format), + fileTitle, + filePath, + log_length_text .. tostring(fileLength), + log_time_text .. tostring(seekTime) + ) + ) + elseif o.file_title_logging == "protocols" and not (starts_protocol(o.logging_protocols, filePath)) then + f:write( + ("[%s] %s | %s | %s"):format( + os.date(o.date_format), + filePath, + log_length_text .. tostring(fileLength), + log_time_text .. tostring(seekTime) + ) + ) + else + f:write( + ("[%s] %s | %s | %s"):format( + os.date(o.date_format), + filePath, + log_length_text .. tostring(fileLength), + log_time_text .. tostring(seekTime) + ) + ) + end + + if action == "copy" then + f:write(" | " .. log_clipboard_text .. action) + end + if action == "paste" then + f:write(" | " .. log_clipboard_text .. action) + end + + f:write("\n") + f:close() + + if not update_seekTime then + seekTime = prev_seekTime + end +end + +----- SmartCopyPaste Specific Code ----- + +table.insert(o.pastable_time_attributes, o.protocols_time_attribute) +table.insert(o.pastable_time_attributes, o.local_time_attribute) +for i = 1, #o.specific_time_attributes do + if not has_value(o.pastable_time_attributes, o.specific_time_attributes[i][2]) then + table.insert(o.pastable_time_attributes, o.specific_time_attributes[i][2]) + end +end + +local clip, clip_time, clip_file +local clipboard_pasted = false + +if not o.device or o.device == "auto" then + if os.getenv("windir") ~= nil then + o.device = "windows" + elseif + os.execute('[ -d "/Applications" ]') == 0 and os.execute('[ -d "/Library" ]') == 0 + or os.execute('[ -d "/Applications" ]') == true and os.execute('[ -d "/Library" ]') == true + then + o.device = "mac" + else + o.device = "linux" + end +end + +function handleres(res, args) + if not res.error and res.status == 0 then + return res.stdout + else + msg.error("There was an error getting " .. o.device .. " clipboard: ") + msg.error(" Status: " .. (res.status or "")) + msg.error(" Error: " .. (res.error or "")) + msg.error(" stdout: " .. (res.stdout or "")) + msg.error("args: " .. utils.to_string(args)) + return "" + end +end + +function os.capture(cmd) + local f = assert(io.popen(cmd, "r")) + local s = assert(f:read("*a")) + f:close() + return s +end + +function make_raw(s) + if not s then + return + end + s = string.gsub(s, "^%s+", "") + s = string.gsub(s, "%s+$", "") + s = string.gsub(s, "[\n\r]+", " ") + return s +end + +function get_extension(path) + if not path then + return + end + + match = string.match(path, "%.([^%.]+)$") + if match == nil then + return "nomatch" + else + return match + end +end + +function get_specific_attribute(target_path) + local pre_attribute = "" + local after_attribute = "" + if not starts_protocol(protocols, target_path) then + pre_attribute = o.local_time_attribute + elseif starts_protocol(protocols, target_path) then + pre_attribute = o.protocols_time_attribute + for i = 1, #o.specific_time_attributes do + if contain_value({ o.specific_time_attributes[i][1] }, target_path) then + pre_attribute = o.specific_time_attributes[i][2] + after_attribute = o.specific_time_attributes[i][3] + break + end + end + end + return pre_attribute, after_attribute +end + +function get_time_attribute(target_path) + local pre_attribute = "" + for i = 1, #o.pastable_time_attributes do + if contain_value({ o.pastable_time_attributes[i] }, target_path) then + pre_attribute = o.pastable_time_attributes[i] + break + end + end + return pre_attribute +end + +function get_clipboard() + local clipboard + if o.device == "linux" then + clipboard = os.capture(o.linux_paste) + return clipboard + elseif o.device == "windows" then + if o.windows_paste == "powershell" then + local args = { + "powershell", + "-NoProfile", + "-Command", + [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + if (-not $clip) { + $clip = Get-Clipboard -Raw -Format FileDropList + } + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]], + } + return handleres(utils.subprocess({ args = args, cancellable = false }), args) + else + clipboard = os.capture(o.windows_paste) + return clipboard + end + elseif o.device == "mac" then + clipboard = os.capture(o.mac_paste) + return clipboard + end + return "" +end + +function set_clipboard(text) + local pipe + if o.device == "linux" then + pipe = io.popen(o.linux_copy, "w") + pipe:write(text) + pipe:close() + elseif o.device == "windows" then + if o.windows_copy == "powershell" then + local res = utils.subprocess({ + args = { + "powershell", + "-NoProfile", + "-Command", + string.format( + [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + Add-Type -AssemblyName PresentationCore + [System.Windows.Clipboard]::SetText('%s') + }]], + text + ), + }, + }) + else + pipe = io.popen(o.windows_copy, "w") + pipe:write(text) + pipe:close() + end + elseif o.device == "mac" then + pipe = io.popen(o.mac_copy, "w") + pipe:write(text) + pipe:close() + end + return "" +end + +function parse_clipboard(text) + if not text then + return + end + + local clip, clip_file, clip_time, pre_attribute + local clip_table = {} + clip = text + + for c in clip:gmatch("[^\n\r]+") do --3.2.1# fix for #80 , accidentally additional "+" was added to the gmatch + local c_pre_attribute, c_clip_file, c_clip_time, c_clip_extension + c = make_raw(c) + + if starts_protocol(protocols, c) then --3.2# handle protocols to allow for space as a seperator + for c_protocols in c:gmatch("[^%s]+") do --3.2# loop iterator using space + if starts_protocol(protocols, c_protocols) then --3.2# check if it starts with protocols again after a space + c_pre_attribute = get_time_attribute(c) + if string.match(c, "(.*)" .. c_pre_attribute) then + c_clip_file = string.match(c_protocols, "(.*)" .. c_pre_attribute) + c_clip_time = tonumber(string.match(c_protocols, c_pre_attribute .. "(%d*%.?%d*)")) + elseif string.match(c, '^"(.*)"$') then + c_clip_file = string.match(c, '^"(.*)"$') + else + c_clip_file = c_protocols + end + c_clip_extension = get_extension(c_clip_file) + table.insert(clip_table, { c_clip_file, c_clip_time, c_clip_extension }) + end + end + else --3.2# otherwise continue as usual with new line seperators only + c_pre_attribute = get_time_attribute(c) + if string.match(c, "(.*)" .. c_pre_attribute) then + c_clip_file = string.match(c, "(.*)" .. c_pre_attribute) + c_clip_time = tonumber(string.match(c, c_pre_attribute .. "(%d*%.?%d*)")) + elseif string.match(c, '^"(.*)"$') then + c_clip_file = string.match(c, '^"(.*)"$') + else + c_clip_file = c + end + + c_clip_extension = get_extension(c_clip_file) + table.insert(clip_table, { c_clip_file, c_clip_time, c_clip_extension }) + end + end + + clip = make_raw(clip) + pre_attribute = get_time_attribute(clip) + + if string.match(clip, "(.*)" .. pre_attribute) then + clip_file = string.match(clip, "(.*)" .. pre_attribute) + clip_time = tonumber(string.match(clip, pre_attribute .. "(%d*%.?%d*)")) + elseif string.match(clip, '^"(.*)"$') then + clip_file = string.match(clip, '^"(.*)"$') + else + clip_file = clip + end + + return clip, clip_file, clip_time, clip_table +end + +function copy() + if filePath ~= nil then + if o.copy_time_method == "none" or copy_time_method == "" then + copy_specific("path") + return + elseif o.copy_time_method == "protocols" and not starts_protocol(protocols, filePath) then + copy_specific("path") + return + elseif o.copy_time_method == "local" and starts_protocol(protocols, filePath) then + copy_specific("path") + return + elseif o.copy_time_method == "specifics" then + if not starts_protocol(protocols, filePath) then + copy_specific("path") + return + else + for i = 1, #o.specific_time_attributes do + if contain_value({ o.specific_time_attributes[i][1] }, filePath) then + copy_specific("path×tamp") + return + end + end + copy_specific("path") + return + end + else + copy_specific("path×tamp") + return + end + else + if o.osd_messages == true then + mp.osd_message("Failed to Copy\nNo Video Found") + end + msg.info("Failed to copy, no video found") + end +end + +function copy_specific(action) + if not action then + return + end + + if filePath == nil then + if o.osd_messages == true then + mp.osd_message("Failed to Copy\nNo Video Found") + end + msg.info("Failed to copy, no video found") + return + else + if action == "title" then + if o.osd_messages == true then + mp.osd_message("Copied:\n" .. fileTitle) + end + set_clipboard(fileTitle) + msg.info("Copied the below into clipboard:\n" .. fileTitle) + end + if action == "path" then + if o.osd_messages == true then + mp.osd_message("Copied:\n" .. filePath) + end + set_clipboard(filePath) + msg.info("Copied and logged the below into clipboard:\n" .. filePath) + write_log(0, false, o.same_entry_limit, "copy") + end + if action == "timestamp" then + local pre_attribute, after_attribute = get_specific_attribute(filePath) + local video_time = mp.get_property_number("time-pos") + if o.osd_messages == true then + mp.osd_message( + "Copied" + .. o.time_seperator + .. format_time(video_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + set_clipboard( + pre_attribute + .. format_time(video_time, o.copy_time_format[3], o.copy_time_format[2], o.copy_time_format[1]) + .. after_attribute + ) + msg.info( + "Copied the below into clipboard:\n" + .. pre_attribute + .. format_time(video_time, o.copy_time_format[3], o.copy_time_format[2], o.copy_time_format[1]) + .. after_attribute + ) + end + if action == "path×tamp" then + local pre_attribute, after_attribute = get_specific_attribute(filePath) + local video_time = mp.get_property_number("time-pos") + if o.osd_messages == true then + mp.osd_message( + "Copied:\n" + .. fileTitle + .. o.time_seperator + .. format_time(video_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + set_clipboard( + filePath + .. pre_attribute + .. format_time(video_time, o.copy_time_format[3], o.copy_time_format[2], o.copy_time_format[1]) + .. after_attribute + ) + msg.info( + "Copied and logged the below into clipboard:\n" + .. filePath + .. pre_attribute + .. format_time(video_time, o.copy_time_format[3], o.copy_time_format[2], o.copy_time_format[1]) + .. after_attribute + ) + write_log(false, false, o.same_entry_limit, "copy") + end + end +end + +function trigger_paste_action(action) + if not action then + return + end + + if action == "load-file" then + filePath = clip_file + if o.osd_messages == true then + if clip_time ~= nil then + mp.osd_message( + "Pasted:\n" + .. clip_file + .. o.time_seperator + .. format_time(clip_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + else + mp.osd_message("Pasted:\n" .. clip_file) + end + end + mp.commandv("loadfile", clip_file) + clipboard_pasted = true + + if clip_time ~= nil then + msg.info("Pasted the below file into mpv:\n" .. clip_file .. format_time(clip_time)) + else + msg.info("Pasted the below file into mpv:\n" .. clip_file) + end + end + + if action == "load-subtitle" then + if o.osd_messages == true then + mp.osd_message("Pasted Subtitle:\n" .. clip_file) + end + mp.commandv("sub-add", clip_file, "select") + msg.info("Pasted the below subtitle into mpv:\n" .. clip_file) + end + + if action == "file-seek" then + local video_duration = mp.get_property_number("duration") + seekTime = clip_time + o.resume_offset + + if seekTime > video_duration then + if o.osd_messages == true then + mp.osd_message( + "Time Paste Exceeds Video Length" + .. o.time_seperator + .. format_time(clip_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info("The time pasted exceeds the video length:\n" .. format_time(clip_time)) + return + end + + if seekTime < 0 then + seekTime = 0 + end + + if o.osd_messages == true then + mp.osd_message( + "Resumed to Pasted Time" + .. o.time_seperator + .. format_time(clip_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + mp.commandv("seek", seekTime, "absolute", "exact") + msg.info("Resumed to the pasted time" .. o.time_seperator .. format_time(clip_time)) + end + + if action == "add-playlist" then + if o.osd_messages == true then + mp.osd_message("Pasted Into Playlist:\n" .. clip_file) + end + mp.commandv("loadfile", clip_file, "append-play") + msg.info("Pasted the below into playlist and added it to the log file:\n" .. clip_file) + + local temp_filePath = filePath + local temp_title_logging = o.file_title_logging + + filePath = clip_file + o.file_title_logging = "none" + write_log(0, false, o.same_entry_limit, "paste") + filePath = temp_filePath + o.file_title_logging = temp_title_logging + end + + if action == "log-force" then + get_list_contents("all", "added-asc") + load(1) + if seekTime > 0 then + if o.osd_messages == true then + mp.osd_message( + "Pasted From Log:\n" + .. list_contents[#list_contents - 1 + 1].found_path + .. o.time_seperator + .. format_time( + list_contents[#list_contents - 1 + 1].found_time, + o.osd_time_format[3], + o.osd_time_format[2], + o.osd_time_format[1] + ) + ) + end + msg.info( + "Pasted the below from log file into mpv:\n" + .. list_contents[#list_contents - 1 + 1].found_path + .. o.time_seperator + .. format_time(list_contents[#list_contents - 1 + 1].found_time) + ) + else + if o.osd_messages == true then + mp.osd_message("Pasted From Log:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + msg.info("Pasted the below from log file into mpv:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + end + + if action == "log-force-noresume" then + get_list_contents("all", "added-asc") + if not list_contents or not list_contents[1] then + return + end + load(1, false, 0) + if o.osd_messages == true then + mp.osd_message("Pasted From Log:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + msg.info("Pasted the below from log file into mpv:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + + if action == "log-playlist" then + get_list_contents("all", "added-asc") + if not list_contents or not list_contents[1] then + return + end + load(1, true) + if o.osd_messages == true then + mp.osd_message("Pasted From Log To Playlist:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + msg.info( + "Pasted the below from log file into mpv playlist:\n" .. list_contents[#list_contents - 1 + 1].found_path + ) + end + + if action == "log-timestamp" then + get_list_contents("all", "added-asc") + if not list_contents or not list_contents[1] then + return + end + local log_time = 0 + for i = #list_contents, 1, -1 do + if list_contents[i].found_path == filePath and tonumber(list_contents[i].found_time) > 0 then + log_time = tonumber(list_contents[i].found_time) + o.resume_offset + break + end + end + if log_time > 0 then + mp.commandv("seek", log_time, "absolute", "exact") + if o.osd_messages == true then + mp.osd_message( + "Pasted Time From Log" + .. o.time_seperator + .. format_time(log_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info("Pasted resume time of video from the log file: " .. format_time(log_time)) + else + list_contents = nil + end + end + + if action == "log-timestamp>playlist" then + get_list_contents("all", "added-asc") + if not list_contents or not list_contents[1] then + return + end + local log_time = 0 + for i = #list_contents, 1, -1 do + if list_contents[i].found_path == filePath and tonumber(list_contents[i].found_time) > 0 then + log_time = tonumber(list_contents[i].found_time) + o.resume_offset + break + end + end + if log_time > 0 then + mp.commandv("seek", log_time, "absolute", "exact") + if o.osd_messages == true then + mp.osd_message( + "Pasted Time From Log" + .. o.time_seperator + .. format_time(log_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info("Pasted resume time of video from the log file: " .. format_time(log_time)) + else + load(1, true) + mp.osd_message("Pasted From Log To Playlist:\n" .. list_contents[#list_contents - 1 + 1].found_path) + msg.info( + "Pasted the below from log file into mpv playlist:\n" + .. list_contents[#list_contents - 1 + 1].found_path + ) + end + end + + if action == "log-timestamp>force" then + get_list_contents("all", "added-asc") + if not list_contents or not list_contents[1] then + return + end + local log_time = 0 + for i = #list_contents, 1, -1 do + if list_contents[i].found_path == filePath and tonumber(list_contents[i].found_time) > 0 then + log_time = tonumber(list_contents[i].found_time) + o.resume_offset + break + end + end + if log_time > 0 then + mp.commandv("seek", log_time, "absolute", "exact") + if o.osd_messages == true then + mp.osd_message( + "Pasted Time From Log" + .. o.time_seperator + .. format_time(log_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info("Pasted resume time of video from the log file: " .. format_time(log_time)) + else + load(1) + if seekTime > 0 then + if o.osd_messages == true then + mp.osd_message( + "Pasted From Log:\n" + .. list_contents[#list_contents - 1 + 1].found_path + .. o.time_seperator + .. format_time( + list_contents[#list_contents - 1 + 1].found_time, + o.osd_time_format[3], + o.osd_time_format[2], + o.osd_time_format[1] + ) + ) + end + msg.info( + "Pasted the below from log file into mpv:\n" + .. list_contents[#list_contents - 1 + 1].found_path + .. o.time_seperator + .. format_time(list_contents[#list_contents - 1 + 1].found_time) + ) + else + if o.osd_messages == true then + mp.osd_message("Pasted From Log:\n" .. list_contents[#list_contents - 1 + 1].found_path) + end + msg.info( + "Pasted the below from log file into mpv:\n" .. list_contents[#list_contents - 1 + 1].found_path + ) + end + end + end + + if action == "error-subtitle" then + if o.osd_messages == true then + mp.osd_message("Subtitle Paste Requires Running Video:\n" .. clip_file) + end + msg.info("Subtitles can only be pasted if a video is running:\n" .. clip_file) + end + + if action == "error-unsupported" then + if o.osd_messages == true then + mp.osd_message("Paste of this item is unsupported possibly due to configuration:\n" .. clip) + end + msg.info( + "Failed to paste into mpv, pasted item shown below is unsupported possibly due to configuration:\n" .. clip + ) + end + + if action == "error-missing" then + if o.osd_messages == true then + mp.osd_message("File Doesn't Exist:\n" .. clip_file) + end + msg.info("The file below doesn't seem to exist:\n" .. clip_file) + end + + if action == "error-time" then + if o.osd_messages == true then + if clip_time ~= nil then + mp.osd_message( + "Time Paste Requires Running Video" + .. o.time_seperator + .. format_time(clip_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + else + mp.osd_message("Time Paste Requires Running Video") + end + end + + if clip_time ~= nil then + msg.info("Time can only be pasted if a video is running:\n" .. format_time(clip_time)) + else + msg.info("Time can only be pasted if a video is running") + end + end + + if action == "error-missingtime" then + if o.osd_messages == true then + mp.osd_message("Clipboard does not contain time for seeking:\n" .. clip) + end + msg.info("Clipboard does not contain the time attribute and time for seeking:\n" .. clip) + end + + if action == "error-samefile" then + if o.osd_messages == true then + mp.osd_message("Pasted file is already running:\n" .. clip) + end + msg.info("Pasted file shown below is already running:\n" .. clip) + end + + if action == "error-unknown" then + if o.osd_messages == true then + mp.osd_message("Paste was ignored due to an error:\n" .. clip) + end + msg.info("Paste was ignored due to an error:\n" .. clip) + end +end + +function multipaste() + if #clip_table < 2 then + return msg.warn("Single paste should be called instead of multipaste") + end + local file_ignored_total = 0 + local file_subtitle_total = 0 + local triggered_multipaste = {} + + if filePath == nil then + for i = 1, #clip_table do + if + file_exists(clip_table[i][1]) and has_value(o.paste_extensions, clip_table[i][3]) + or starts_protocol(o.paste_protocols, clip_table[i][1]) + then + filePath = clip_table[i][1] + mp.commandv("loadfile", clip_table[i][1]) + clipboard_pasted = true + table.remove(clip_table, i) + triggered_multipaste[1] = true + break + end + end + end + + if filePath ~= nil then + for i = 1, #clip_table do + if + file_exists(clip_table[i][1]) and has_value(o.paste_extensions, clip_table[i][3]) + or starts_protocol(o.paste_protocols, clip_table[i][1]) + then + mp.commandv("loadfile", clip_table[i][1], "append-play") + triggered_multipaste[2] = true + + local temp_filePath = filePath + local temp_title_logging = o.file_title_logging + filePath = clip_table[i][1] + o.file_title_logging = "none" + write_log(0, false, o.same_entry_limit, "paste") + filePath = temp_filePath + o.file_title_logging = temp_title_logging + elseif file_exists(clip_table[i][1]) and has_value(o.paste_subtitles, clip_table[i][3]) then + mp.commandv("sub-add", clip_table[i][1]) + file_subtitle_total = file_subtitle_total + 1 + elseif + not has_value(o.paste_extensions, clip_table[i][3]) + and not has_value(o.paste_subtitles, clip_table[i][3]) + then + msg.warn( + "The below was ignored since it is unsupported possibly due to configuration:\n" .. clip_table[i][1] + ) + file_ignored_total = file_ignored_total + 1 + elseif not file_exists(clip_table[i][1]) then + msg.warn("The below doesn't seem to exist:\n" .. clip_table[i][1]) + file_ignored_total = file_ignored_total + 1 + else + msg.warn("The below was ignored due to an error:\n" .. clip_table[i][1]) + file_ignored_total = file_ignored_total + 1 + end + end + end + + local osd_msg = "" + if triggered_multipaste[1] == true then + if osd_msg ~= "" then + osd_msg = osd_msg .. "\n" + end + osd_msg = osd_msg .. "Pasted: " .. filePath + end + if file_subtitle_total > 0 then + if osd_msg ~= "" then + osd_msg = osd_msg .. "\n" + end + osd_msg = osd_msg .. "Added " .. file_subtitle_total .. " Subtitle/s" + end + if triggered_multipaste[2] == true then + if osd_msg ~= "" then + osd_msg = osd_msg .. "\n" + end + osd_msg = osd_msg + .. "Added Into Playlist " + .. #clip_table - file_ignored_total - file_subtitle_total + .. " item/s" + end + if file_ignored_total > 0 then + if osd_msg ~= "" then + osd_msg = osd_msg .. "\n" + end + osd_msg = osd_msg .. "Ignored " .. file_ignored_total .. " Item/s" + end + + if osd_msg == "" then + osd_msg = "Pasted Items Ignored or Unable To Append Into Video:\n" .. clip + end + + if o.osd_messages == true then + mp.osd_message(osd_msg) + end + msg.info(osd_msg) +end + +function paste() + if o.osd_messages == true then + mp.osd_message("Pasting...") + end + msg.info("Pasting...") + + clip = get_clipboard(clip) + if not clip then + msg.error("Error: clip is null" .. clip) + return + end + clip, clip_file, clip_time, clip_table = parse_clipboard(clip) + + if #clip_table > 1 then + multipaste() + else + local currentVideoExtension = string.lower(get_extension(clip_file)) + if filePath == nil then + if + file_exists(clip_file) and has_value(o.paste_extensions, currentVideoExtension) + or starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("load-file") + elseif file_exists(clip_file) and has_value(o.paste_subtitles, currentVideoExtension) then + trigger_paste_action("error-subtitle") + elseif + not has_value(o.paste_extensions, currentVideoExtension) + and not has_value(o.paste_subtitles, currentVideoExtension) + then + trigger_paste_action("log-" .. o.log_paste_idle_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unsupported") + end + elseif not file_exists(clip_file) then + trigger_paste_action("log-" .. o.log_paste_idle_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-missing") + end + else + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unknown") + end + end + else + if file_exists(clip_file) and has_value(o.paste_subtitles, currentVideoExtension) then + trigger_paste_action("load-subtitle") + elseif o.running_paste_behavior == "playlist" then + if + filePath ~= clip_file + and file_exists(clip_file) + and has_value(o.paste_extensions, currentVideoExtension) + or filePath ~= clip_file and starts_protocol(o.paste_protocols, clip_file) + or filePath == clip_file and file_exists(clip_file) and has_value( + o.paste_extensions, + currentVideoExtension + ) and clip_time == nil + or filePath == clip_file and starts_protocol(o.paste_protocols, clip_file) and clip_time == nil + then + trigger_paste_action("add-playlist") + elseif clip_time ~= nil then + trigger_paste_action("file-seek") + elseif + not has_value(o.paste_extensions, currentVideoExtension) + and not has_value(o.paste_subtitles, currentVideoExtension) + then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unsupported") + end + elseif not file_exists(clip_file) then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-missing") + end + else + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unknown") + end + end + elseif o.running_paste_behavior == "timestamp" then + if clip_time ~= nil then + trigger_paste_action("file-seek") + elseif + file_exists(clip_file) and has_value(o.paste_extensions, currentVideoExtension) + or starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("add-playlist") + elseif not has_value(o.paste_extensions, currentVideoExtension) then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unsupported") + end + elseif not file_exists(clip_file) then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-missing") + end + else + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unknown") + end + end + elseif o.running_paste_behavior == "force" then + if + filePath ~= clip_file + and file_exists(clip_file) + and has_value(o.paste_extensions, currentVideoExtension) + or filePath ~= clip_file and starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("load-file") + elseif clip_time ~= nil then + trigger_paste_action("file-seek") + elseif + file_exists(clip_file) and filePath == clip_file + or filePath == clip_file and starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("add-playlist") + elseif not has_value(o.paste_extensions, currentVideoExtension) then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unsupported") + end + elseif not file_exists(clip_file) then + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-missing") + end + else + trigger_paste_action("log-" .. o.log_paste_running_behavior) + if not list_contents or not list_contents[1] then + trigger_paste_action("error-unknown") + end + end + end + end + end +end + +function paste_specific(action) + if not action then + return + end + + if o.osd_messages == true then + mp.osd_message("Pasting...") + end + msg.info("Pasting...") + + clip = get_clipboard(clip) + if not clip then + msg.error("Error: clip is null" .. clip) + return + end + clip, clip_file, clip_time, clip_table = parse_clipboard(clip) + + if #clip_table > 1 then + multipaste() + else + local currentVideoExtension = string.lower(get_extension(clip_file)) + if action == "playlist" then + if + file_exists(clip_file) and has_value(o.paste_extensions, currentVideoExtension) + or starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("add-playlist") + elseif + not has_value(o.paste_extensions, currentVideoExtension) + and not has_value(o.paste_subtitles, currentVideoExtension) + then + trigger_paste_action("error-unsupported") + elseif not file_exists(clip_file) then + trigger_paste_action("error-missing") + else + trigger_paste_action("error-unknown") + end + end + + if action == "timestamp" then + if filePath == nil then + trigger_paste_action("error-time") + elseif clip_time ~= nil then + trigger_paste_action("file-seek") + elseif clip_time == nil then + trigger_paste_action("error-missingtime") + elseif + not has_value(o.paste_extensions, currentVideoExtension) + and not has_value(o.paste_subtitles, currentVideoExtension) + then + trigger_paste_action("error-unsupported") + elseif not file_exists(clip_file) then + trigger_paste_action("error-missing") + else + trigger_paste_action("error-unknown") + end + end + + if action == "force" then + if + filePath ~= clip_file + and file_exists(clip_file) + and has_value(o.paste_extensions, currentVideoExtension) + or filePath ~= clip_file and starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("load-file") + elseif + file_exists(clip_file) and filePath == clip_file + or filePath == clip_file and starts_protocol(o.paste_protocols, clip_file) + then + trigger_paste_action("error-samefile") + elseif + not has_value(o.paste_extensions, currentVideoExtension) + and not has_value(o.paste_subtitles, currentVideoExtension) + then + trigger_paste_action("error-unsupported") + elseif not file_exists(clip_file) then + trigger_paste_action("error-missing") + else + trigger_paste_action("error-unknown") + end + end + end +end + +mp.register_event("file-loaded", function() + list_close_and_trash_collection() + filePath, fileTitle, fileLength = get_file() + if clipboard_pasted == true then + clip = get_clipboard(clip) + if not clip then + msg.error("Error: clip is null" .. clip) + return + end + clip, clip_file, clip_time, clip_table = parse_clipboard(clip) + + if #clip_table > 1 then + for i = 1, #clip_table do + if + file_exists(clip_table[i][1]) and has_value(o.paste_extensions, clip_table[i][3]) + or starts_protocol(o.paste_protocols, clip_table[i][1]) + then + clip_file = clip_table[i][1] + clip_time = clip_table[i][2] + break + end + end + end + + local video_duration = mp.get_property_number("duration") + if not clip_time or clip_time > video_duration or clip_time <= 0 then + write_log(0, false, o.same_entry_limit, "paste") + else + write_log(clip_time, false, o.same_entry_limit, "paste") + end + if filePath == clip_file and clip_time ~= nil then + seekTime = clip_time + o.resume_offset + + if seekTime > video_duration then + if o.osd_messages == true then + mp.osd_message( + "Time Paste Exceeds Video Length" + .. o.time_seperator + .. format_time(clip_time, o.osd_time_format[3], o.osd_time_format[2], o.osd_time_format[1]) + ) + end + msg.info("The time pasted exceeds the video length:\n" .. format_time(clip_time)) + return + end + + if seekTime < 0 then + seekTime = 0 + end + + mp.commandv("seek", seekTime, "absolute", "exact") + clipboard_pasted = false + end + end + + if resume_selected == true and seekTime ~= nil then + mp.commandv("seek", seekTime, "absolute", "exact") + resume_selected = false + end + mark_chapter() +end) + +mp.observe_property("idle-active", "bool", function(_, v) + if v and has_value(available_filters, o.auto_run_list_idle) then + display_list(o.auto_run_list_idle, nil, "hide-osd") + end +end) + +bind_keys(o.copy_keybind, "copy", copy) +bind_keys(o.copy_specific_keybind, "copy-specific", function() + copy_specific(o.copy_specific_behavior) +end) +bind_keys(o.paste_keybind, "paste", paste) +bind_keys(o.paste_specific_keybind, "paste-specific", function() + paste_specific(o.paste_specific_behavior) +end) + +for i = 1, #o.open_list_keybind do + if i == 1 then + mp.add_forced_key_binding(o.open_list_keybind[i][1], "open-list", function() + display_list(o.open_list_keybind[i][2]) + end) + else + mp.add_forced_key_binding(o.open_list_keybind[i][1], "open-list" .. i, function() + display_list(o.open_list_keybind[i][2]) + end) + end +end diff --git a/mac/.config/mpv/scripts/SmartSkip.lua b/mac/.config/mpv/scripts/SmartSkip.lua new file mode 100644 index 0000000..05ce108 --- /dev/null +++ b/mac/.config/mpv/scripts/SmartSkip.lua @@ -0,0 +1,1936 @@ +-- Copyright (c) 2023, Eisa AlAwadhi +-- License: BSD 2-Clause License +-- Creator: Eisa AlAwadhi +-- Project: SmartSkip +-- Version: 1.2 +-- Date: 23-09-2023 + +-- Related forked projects: +-- https://github.com/detuur/mpv-scripts/blob/master/skiptosilence.lua +-- https://raw.githubusercontent.com/mpv-player/mpv/master/TOOLS/lua/autoload.lua +-- https://github.com/mar04/chapters_for_mpv +-- https://github.com/po5/chapterskip/blob/master/chapterskip.lua + +local o = { + -----Silence Skip Settings----- + silence_audio_level = -40, + silence_duration = 0.65, + ignore_silence_duration = 5, + min_skip_duration = 0, + max_skip_duration = 130, + keybind_twice_cancel_skip = true, + silence_skip_to_end = "playlist-next", + add_chapter_on_skip = true, + force_mute_on_skip = false, + -----Smart Skip Settings----- + last_chapter_skip_behavior = [[ [ ["no-chapters", "silence-skip"], ["internal-chapters", "playlist-next"], ["external-chapters", "silence-skip"] ] ]], + smart_next_proceed_countdown = true, + smart_prev_cancel_countdown = true, + -----Chapters Settings----- + external_chapters_autoload = true, + modified_chapters_autosave = [[ ["no-chapters", "external-chapters"] ]], + global_chapters = true, + global_chapters_path = "/:dir%mpvconf%/chapters", + hash_global_chapters = true, + add_chapter_ask_title = false, + add_chapter_pause_for_input = false, + add_chapter_placeholder_title = "Chapter ", + -----Auto-Skip Settings----- + autoskip_chapter = true, + autoskip_countdown = 3, + autoskip_countdown_bulk = false, + autoskip_countdown_graceful = false, + skip_once = false, + categories = [[ [ ["internal-chapters", "prologue>Prologue/^Intro; opening>^OP/ OP$/^Opening; ending>^ED/ ED$/^Ending; preview>Preview$"], ["external-chapters", "idx->0/2"] ] ]], + skip = [[ [ ["internal-chapters", "toggle;toggle_idx;opening;ending;preview"], ["external-chapters", "toggle;toggle_idx"] ] ]], + -----Autoload Settings----- + autoload_playlist = true, + autoload_max_entries = 5000, + autoload_max_dir_stack = 20, + ignore_hidden = true, + same_type = false, + directory_mode = "auto", + images = true, + videos = true, + audio = true, + additional_image_exts = "", + additional_video_exts = "", + additional_audio_exts = "", + -----OSD Messages Settings----- + osd_duration = 2500, + seek_osd = "osd-msg-bar", + chapter_osd = "osd-msg-bar", + autoskip_osd = "osd-msg-bar", + playlist_osd = true, + osd_msg = true, + -----Keybind Settings----- + toggle_autoload_keybind = [[ [""] ]], + toggle_autoskip_keybind = [[ ["ctrl+."] ]], + toggle_category_autoskip_keybind = [[ ["alt+."] ]], + cancel_autoskip_countdown_keybind = [[ ["esc", "n"] ]], + proceed_autoskip_countdown_keybind = [[ ["enter", "y"] ]], + add_chapter_keybind = [[ ["n"] ]], + remove_chapter_keybind = [[ ["alt+n"] ]], + edit_chapter_keybind = [[ [""] ]], + write_chapters_keybind = [[ ["ctrl+n"] ]], + bake_chapters_keybind = [[ [""] ]], + chapter_next_keybind = [[ ["ctrl+right"] ]], + chapter_prev_keybind = [[ ["ctrl+left"] ]], + smart_next_keybind = [[ [">"] ]], + smart_prev_keybind = [[ ["<"] ]], + silence_skip_keybind = [[ ["?"] ]], +} + +local mp = require("mp") +local msg = require("mp.msg") +local utils = require("mp.utils") +local options = require("mp.options") +options.read_options(o, nil, function(list) + split_option_exts(list.additional_video_exts, list.additional_audio_exts, list.additional_image_exts) + if + list.videos + or list.additional_video_exts + or list.audio + or list.additional_audio_exts + or list.images + or list.additional_image_exts + then + create_extensions() + end + if list.directory_mode then + validate_directory_mode() + end +end) + +if o.add_chapter_on_skip ~= false and o.add_chapter_on_skip ~= true then + o.add_chapter_on_skip = utils.parse_json(o.add_chapter_on_skip) +end +if o.modified_chapters_autosave ~= false and o.modified_chapters_autosave ~= true then + o.modified_chapters_autosave = utils.parse_json(o.modified_chapters_autosave) +end +o.last_chapter_skip_behavior = utils.parse_json(o.last_chapter_skip_behavior) +if utils.parse_json(o.skip) ~= nil then + o.skip = utils.parse_json(o.skip) +end +if utils.parse_json(o.categories) ~= nil then + o.categories = utils.parse_json(o.categories) +end +if o.skip_once ~= false and o.skip_once ~= true then + o.skip_once = utils.parse_json(o.skip_once) +end + +if o.global_chapters_path:match("/:dir%%mpvconf%%") then --1.2# add variables for specifying path via user-config + o.global_chapters_path = o.global_chapters_path:gsub("/:dir%%mpvconf%%", mp.find_config_file(".")) +elseif o.global_chapters_path:match("/:dir%%script%%") then + o.global_chapters_path = o.global_chapters_path:gsub("/:dir%%script%%", debug.getinfo(1).source:match("@?(.*/)")) +elseif o.global_chapters_path:match("/:var%%(.*)%%") then + local os_variable = o.global_chapters_path:match("/:var%%(.*)%%") + o.global_chapters_path = o.global_chapters_path:gsub("/:var%%(.*)%%", os.getenv(os_variable)) +end + +o.toggle_autoload_keybind = utils.parse_json(o.toggle_autoload_keybind) +o.toggle_autoskip_keybind = utils.parse_json(o.toggle_autoskip_keybind) +o.cancel_autoskip_countdown_keybind = utils.parse_json(o.cancel_autoskip_countdown_keybind) +o.proceed_autoskip_countdown_keybind = utils.parse_json(o.proceed_autoskip_countdown_keybind) +o.toggle_category_autoskip_keybind = utils.parse_json(o.toggle_category_autoskip_keybind) +o.add_chapter_keybind = utils.parse_json(o.add_chapter_keybind) +o.remove_chapter_keybind = utils.parse_json(o.remove_chapter_keybind) +o.write_chapters_keybind = utils.parse_json(o.write_chapters_keybind) +o.edit_chapter_keybind = utils.parse_json(o.edit_chapter_keybind) +o.bake_chapters_keybind = utils.parse_json(o.bake_chapters_keybind) +o.chapter_prev_keybind = utils.parse_json(o.chapter_prev_keybind) +o.chapter_next_keybind = utils.parse_json(o.chapter_next_keybind) +o.smart_prev_keybind = utils.parse_json(o.smart_prev_keybind) +o.smart_next_keybind = utils.parse_json(o.smart_next_keybind) +o.silence_skip_keybind = utils.parse_json(o.silence_skip_keybind) + +package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path +local user_input_module, input = pcall(require, "user-input-module") + +if o.osd_duration == -1 then + o.osd_duration = (mp.get_property_number("osd-duration") or 1000) +end +local speed_state = 1 +local pause_state = false +local mute_state = false +local sub_state = nil +local secondary_sub_state = nil +local vid_state = nil +local skip_flag = false +local window_state = nil +local force_silence_skip = false +local initial_skip_time = 0 +local initial_chapter_count = 0 +local chapter_state = "no-chapters" +local file_length = 0 +local keep_open_state = "yes" +if mp.get_property("config") ~= "no" then + keep_open_state = mp.get_property("keep-open") +end +local osd_duration_default = (mp.get_property_number("osd-duration") or 1000) +local autoload_playlist = o.autoload_playlist +local autoskip_chapter = o.autoskip_chapter +local playlist_osd = false +local autoskip_playlist_osd = false +local g_playlist_pos = 0 +local g_opt_categories = o.categories +local g_opt_skip_once = false +o.autoskip_countdown = math.floor(o.autoskip_countdown) +local g_autoskip_countdown = o.autoskip_countdown +local g_autoskip_countdown_flag = false +local categories = { + toggle = "", + toggle_idx = "", +} +local autoskip_osd = o.autoskip_osd +if o.autoskip_osd == "osd-msg-bar" then + autoskip_osd = "osd-bar" +end +if o.autoskip_osd == "osd-msg" then + autoskip_osd = "no-osd" +end + +-- utility functions -- +function has_value(tab, val, array2d) + if not tab then + return msg.error("check value passed") + end + if not val then + return msg.error("check value passed") + end + if not array2d then + for index, value in ipairs(tab) do + if string.lower(value) == string.lower(val) then + return true + end + end + end + if array2d then + for i = 1, #tab do + if tab[i] and string.lower(tab[i][array2d]) == string.lower(val) then + return true + end + end + end + + return false +end + +function esc_string(str) + return str:gsub("([%p])", "%%%1") +end + +function prompt_msg(text, duration) + if not text then + return + end + if not duration then + duration = o.osd_duration + end + if o.osd_msg then + mp.commandv("show-text", text, duration) + end + msg.info(text) +end + +function bind_keys(keys, name, func, opts) + if not keys then + mp.add_forced_key_binding(keys, name, func, opts) + return + end + + for i = 1, #keys do + if i == 1 then + mp.add_forced_key_binding(keys[i], name, func, opts) + else + mp.add_forced_key_binding(keys[i], name .. i, func, opts) + end + end +end + +function unbind_keys(keys, name) + if not keys then + mp.remove_key_binding(name) + return + end + + for i = 1, #keys do + if i == 1 then + mp.remove_key_binding(name) + else + mp.remove_key_binding(name .. i) + end + end +end + +-- skip-silence utility functions -- +function restoreProp(timepos, pause) + if not timepos then + timepos = mp.get_property_number("time-pos") + end + if not pause then + pause = pause_state + end + + mp.set_property("vid", vid_state) + mp.set_property("force-window", window_state) + mp.set_property_bool("mute", mute_state) + mp.set_property("speed", speed_state) + mp.unobserve_property(foundSilence) + mp.command("no-osd af remove @skiptosilence") + mp.set_property_bool("pause", pause) + mp.set_property_number("time-pos", timepos) + mp.set_property("sub-visibility", sub_state) + mp.set_property("secondary-sub-visibility", secondary_sub_state) + timer:kill() + skip_flag = false +end + +function handleMinMaxDuration(timepos) + if not skip_flag then + return + end + if not timepos then + timepos = mp.get_property_number("time-pos") + end + + skip_duration = timepos - initial_skip_time + if o.min_skip_duration > 0 and skip_duration <= o.min_skip_duration then + restoreProp(initial_skip_time) + prompt_msg("Skipping Cancelled\nSilence less than minimum") + return true + end + if o.max_skip_duration > 0 and skip_duration >= o.max_skip_duration then + restoreProp(initial_skip_time) + prompt_msg("Skipping Cancelled\nSilence is more than configured maximum") + return true + end + return false +end + +function setKeepOpenState() + if o.silence_skip_to_end == "playlist-next" then + mp.set_property("keep-open", "yes") + else + mp.set_property("keep-open", "always") + end +end + +function eofHandler(name, val) + if val and skip_flag then + if o.silence_skip_to_end == "playlist-next" then + restoreProp((mp.get_property_native("duration") or 0)) + if mp.get_property_native("playlist-playing-pos") + 1 == mp.get_property_native("playlist-count") then + prompt_msg("Skipped to end at " .. mp.get_property_osd("duration")) + else + mp.commandv("playlist-next") + end + elseif o.silence_skip_to_end == "cancel" then + prompt_msg("Skipping Cancelled\nSilence not detected") + restoreProp(initial_skip_time) + elseif o.silence_skip_to_end == "pause" then + prompt_msg("Skipped to end at " .. mp.get_property_osd("duration")) + restoreProp((mp.get_property_native("duration") or 0), true) + end + end +end + +-- smart-skip main code -- +function smartNext() + if g_autoskip_countdown_flag and o.smart_next_proceed_countdown then + proceed_autoskip(true) + return + end + local next_action = "silence-skip" + local chapters_count = (mp.get_property_number("chapters") or 0) + local chapter = (mp.get_property_number("chapter") or 0) + local current_playlist = (mp.get_property_native("playlist-playing-pos") + 1 or 0) + local total_playlist = (mp.get_property_native("playlist-count") or 0) + + if chapter + 2 <= chapters_count then + next_action = "chapter-next" + elseif + chapter + 2 > chapters_count and (initial_chapter_count == 0 or chapters_count == 0 or force_silence_skip) + then + if chapters_count == 0 then + force_silence_skip = true + end + next_action = "silence-skip" + elseif chapter + 1 >= chapters_count then + for i = 1, #o.last_chapter_skip_behavior do + if o.last_chapter_skip_behavior[i] and o.last_chapter_skip_behavior[i][1] == chapter_state then + next_action = o.last_chapter_skip_behavior[i][2] + break + end + end + end + + if next_action == "playlist-next" and current_playlist == total_playlist then + next_action = "chapter-next" + end + + if next_action == "silence-skip" then + silenceSkip() + end + if next_action == "chapter-next" then + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(o.chapter_osd, "add", "chapter", 1) + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + end + if next_action == "playlist-next" then + mp.command("playlist_next") + end +end + +function smartPrev() + if skip_flag then + restoreProp(initial_skip_time) + return + end + if g_autoskip_countdown_flag and o.smart_prev_cancel_countdown then + kill_chapterskip_countdown("osd") + return + end + local chapters_count = (mp.get_property_number("chapters") or 0) + local chapter = (mp.get_property_number("chapter") or 0) + local timepos = (mp.get_property_native("time-pos") or 0) + + if chapter - 1 < 0 and timepos > 1 and chapters_count == 0 then + mp.commandv("seek", 0, "absolute", "exact") + + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(o.seek_osd, "show-progress") + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + elseif chapter - 1 < 0 and timepos < 1 then + mp.command("playlist_prev") + elseif chapter - 1 <= chapters_count then + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(o.chapter_osd, "add", "chapter", -1) + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + end +end + +-- chapter-next/prev main code -- +function chapterSeek(direction) + if skip_flag and direction == -1 then + restoreProp(initial_skip_time) + return + end + + local chapters_count = (mp.get_property_number("chapters") or 0) + local chapter = (mp.get_property_number("chapter") or 0) + local timepos = (mp.get_property_native("time-pos") or 0) + + if chapter + direction < 0 and timepos > 1 and chapters_count == 0 then + mp.commandv("seek", 0, "absolute", "exact") + + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(o.seek_osd, "show-progress") + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + elseif chapter + direction < 0 and timepos < 1 then + mp.command("playlist_prev") + elseif chapter + direction >= chapters_count then + mp.command("playlist_next") + else + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(o.chapter_osd, "add", "chapter", direction) + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + end +end + +-- silence skip main code -- +function silenceSkip(action) + if skip_flag then + if o.keybind_twice_cancel_skip then + restoreProp(initial_skip_time) + end + return + end + initial_skip_time = (mp.get_property_native("time-pos") or 0) + if math.floor(initial_skip_time) == math.floor(mp.get_property_native("duration") or 0) then + return + end + local width = mp.get_property_native("osd-width") + local height = mp.get_property_native("osd-height") + mp.set_property_native("geometry", ("%dx%d"):format(width, height)) + mp.commandv(o.seek_osd, "show-progress") + + mp.command( + "no-osd af add @skiptosilence:lavfi=[silencedetect=noise=" + .. o.silence_audio_level + .. "dB:d=" + .. o.silence_duration + .. "]" + ) + + mp.observe_property("af-metadata/skiptosilence", "string", foundSilence) + + sub_state = mp.get_property("sub-visibility") + mp.set_property("sub-visibility", "no") + secondary_sub_state = mp.get_property("secondary-sub-visibility") + mp.set_property("secondary-sub-visibility", "no") + window_state = mp.get_property("force-window") + mp.set_property("force-window", "yes") + vid_state = mp.get_property("vid") + mp.set_property("vid", "no") + mute_state = mp.get_property_native("mute") + if o.force_mute_on_skip then + mp.set_property_bool("mute", true) + end + pause_state = mp.get_property_native("pause") + mp.set_property_bool("pause", false) + speed_state = mp.get_property_native("speed") + mp.set_property("speed", 100) + setKeepOpenState() + skip_flag = true + + timer = mp.add_periodic_timer(0.5, function() + local video_time = (mp.get_property_native("time-pos") or 0) + handleMinMaxDuration(video_time) + if skip_flag then + mp.commandv(o.seek_osd, "show-progress") + end + end) +end + +function foundSilence(name, value) + if value == "{}" or value == nil then + return + end + + timecode = tonumber(string.match(value, "%d+%.?%d+")) + if timecode == nil or timecode < initial_skip_time + o.ignore_silence_duration then + return + end + + if handleMinMaxDuration(timecode) then + return + end + + restoreProp(timecode) + + mp.add_timeout(0.05, function() + prompt_msg("Skipped to silence 🕒 " .. mp.get_property_osd("time-pos")) + end) + if o.add_chapter_on_skip == true or has_value(o.add_chapter_on_skip, chapter_state) then + mp.add_timeout(0.05, add_chapter) + end + skip_flag = false +end + +-- modified fork of chapters_for_mpv -- +--[[ +Copyright (c) 2023 Mariusz Libera <mariusz.libera@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--]] + +-- to debug run mpv with arg: --msg-level=SmartSkip=debug +-- to test o.run mpv with arg: --script-opts=SmartSkip-OPTION=VALUE + +local chapters_modified = false + +msg.debug("options:", utils.to_string(options)) + +-- CHAPTER MANIPULATION -------------------------------------------------------- + +function change_title_callback(user_input, err, chapter_index) + if user_input == nil or err ~= nil then + msg.warn("no chapter title provided:", err) + return + end + + local chapter_list = mp.get_property_native("chapter-list") + + if chapter_index > mp.get_property_number("chapter-list/count") then + msg.warn("can't set chapter title") + return + end + + chapter_list[chapter_index].title = user_input + + mp.set_property_native("chapter-list", chapter_list) + chapters_modified = true +end + +function edit_chapter() + local mpv_chapter_index = mp.get_property_number("chapter") + local chapter_list = mp.get_property_native("chapter-list") + + if mpv_chapter_index == nil or mpv_chapter_index == -1 then + msg.verbose("no chapter selected, nothing to edit") + return + end + + if not user_input_module then + msg.error("no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input") + return + end + input.get_user_input(change_title_callback, { + request_text = "title of the chapter:", + default_input = chapter_list[mpv_chapter_index + 1].title, + cursor_pos = #chapter_list[mpv_chapter_index + 1].title + 1, + }, mpv_chapter_index + 1) + + if o.add_chapter_pause_for_input then + mp.set_property_bool("pause", true) + mp.osd_message(" ", 0.1) + end +end + +function add_chapter(timepos) + if not timepos then + timepos = mp.get_property_number("time-pos") + end + local chapter_list = mp.get_property_native("chapter-list") + + if #chapter_list > 0 then + for i = 1, #chapter_list do + if math.floor(chapter_list[i].time) == math.floor(timepos) then + msg.debug("failed to add chapter, chapter exists in same position") + return + end + end + end + + local chapter_index = (mp.get_property_number("chapter") or -1) + 2 + + table.insert(chapter_list, chapter_index, { title = "", time = timepos }) + + msg.debug("inserting new chapter at ", chapter_index, " chapter_", " time: ", timepos) + + mp.set_property_native("chapter-list", chapter_list) + chapters_modified = true + + if o.add_chapter_ask_title then + if not user_input_module then + msg.error( + "no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input" + ) + return + end + -- ask user for chapter title + input.get_user_input(change_title_callback, { + request_text = "title of the chapter:", + default_input = o.placeholder_title .. chapter_index, + cursor_pos = #(o.placeholder_title .. chapter_index) + 1, + }, chapter_index) + + if o.add_chapter_pause_for_input then + mp.set_property_bool("pause", true) + -- FIXME: for whatever reason osd gets hidden when we pause the + -- playback like that, workaround to make input prompt appear + -- right away without requiring mouse or keyboard action + mp.osd_message(" ", 0.1) + end + end +end + +function remove_chapter() + local chapter_count = mp.get_property_number("chapter-list/count") + + if chapter_count < 1 then + msg.verbose("no chapters to remove") + return + end + + local chapter_list = mp.get_property_native("chapter-list") + local current_chapter = mp.get_property_number("chapter") + 1 + + table.remove(chapter_list, current_chapter) + msg.debug("removing chapter", current_chapter) + + mp.set_property_native("chapter-list", chapter_list) + chapters_modified = true +end + +-- UTILITY FUNCTIONS ----------------------------------------------------------- + +function detect_os() + if package.config:sub(1, 1) == "/" then + return "unix" + else + return "windows" + end +end + +-- for unix use only +-- returns a table of command path and varargs, or nil if command was not found +function command_exists(command, ...) + msg.debug("looking for command:", command) + -- msg.debug("args:", ) + local process = mp.command_native({ + name = "subprocess", + capture_stdout = true, + capture_stderr = true, + playback_only = false, + args = { "sh", "-c", "command -v -- " .. command }, + }) + + if process.status == 0 then + local command_path = process.stdout:gsub("\n", "") + msg.debug("command found:", command_path) + return { command_path, ... } + else + msg.debug("command not found:", command) + return nil + end +end + +function mkdir(path) + local args = nil + + if detect_os() == "unix" then + args = { "mkdir", "-p", "--", path } + else + args = { "powershell", "-NoProfile", "-Command", "mkdir", path } + end + + local process = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = args, + }) + + if process.status == 0 then + msg.debug("mkdir success:", path) + return true + else + msg.error("mkdir failure:", path) + return false + end +end + +-- returns md5 hash of the full path of the current media file +function hash() + local path = mp.get_property("path") + if path == nil then + msg.debug("something is wrong with the path, can't get full_path, can't hash it") + return + end + + msg.debug("hashing:", path) + + local cmd = { + name = "subprocess", + capture_stdout = true, + playback_only = false, + } + local args = nil + + if detect_os() == "unix" then + local md5 = command_exists("md5sum") + or command_exists("md5") + or command_exists("openssl", "md5 | cut -d ' ' -f 2") + if md5 == nil then + msg.warn("no md5 command found, can't generate hash") + return + end + md5 = table.concat(md5, " ") + cmd["stdin_data"] = path + args = { "sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" } + else --windows + -- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3 + local hash_command = '$s = [System.IO.MemoryStream]::new(); $w = [System.IO.StreamWriter]::new($s); $w.write("' + .. path + .. '"); $w.Flush(); $s.Position = 0; Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash' + args = { "powershell", "-NoProfile", "-Command", hash_command } + end + cmd["args"] = args + msg.debug("hash cmd:", utils.to_string(cmd)) + local process = mp.command_native(cmd) + + if process.status == 0 then + local hash = process.stdout:gsub("%s+", "") + msg.debug("hash:", hash) + return hash + else + msg.warn("hash function failed") + return + end +end + +function construct_ffmetadata() + local path = mp.get_property("path") + if path == nil then + msg.debug("something is wrong with the path, can't get full_path") + return + end + + local chapter_count = mp.get_property_number("chapter-list/count") + local all_chapters = mp.get_property_native("chapter-list") + + local ffmetadata = ";FFMETADATA1\n;file=" .. path + + for i, c in ipairs(all_chapters) do + local c_title = c.title + local c_start = c.time * 1000000000 + local c_end + + if i < chapter_count then + c_end = all_chapters[i + 1].time * 1000000000 + else + c_end = (mp.get_property_number("duration") or c.time) * 1000000000 + end + + msg.debug(i, "c_title", c_title, "c_start:", c_start, "c_end", c_end) + + ffmetadata = ffmetadata .. "\n[CHAPTER]\nSTART=" .. c_start .. "\nEND=" .. c_end .. "\ntitle=" .. c_title + end + + return ffmetadata +end + +-- FILE IO --------------------------------------------------------------------- + +-- args: +-- osd - if true, display an osd message +-- force -- if true write chapters file even if there are no changes +-- on success returns path of the chapters file, nil on failure +function write_chapters(...) + local chapters_count = (mp.get_property_number("chapters") or 0) + local osd, force = ... + if not force and not chapters_modified then + msg.debug("nothing to write") + return + end + if initial_chapter_count == 0 and chapters_count == 0 then + return + end + + -- figure out the directory + local chapters_dir + if o.global_chapters then + local dir = utils.file_info(o.global_chapters_path) + if dir then + if dir.is_dir then + msg.debug("o.global_chapters_path exists:", o.global_chapters_path) + chapters_dir = o.global_chapters_path + else + msg.error("o.global_chapters_path is not a directory") + return + end + else + msg.verbose("o.global_chapters_path doesn't exists:", o.global_chapters_path) + if mkdir(o.global_chapters_path) then + chapters_dir = o.global_chapters_path + else + return + end + end + else + chapters_dir = utils.split_path(mp.get_property("path")) + end + + -- and the name + local name = mp.get_property("filename") + if o.hash_global_chapters and o.global_chapters then + name = hash() + if name == nil then + msg.warn("hash function failed, fallback to filename") + name = mp.get_property("filename") + end + end + + local chapters_file_path = utils.join_path(chapters_dir, name .. ".ffmetadata") + --1.09#HERE I SHOULD ADD SOME SORT OF DELETE FUNCTION IN CASE CHAPTER COUNT IS 0 + msg.debug("opening for writing:", chapters_file_path) + local chapters_file = io.open(chapters_file_path, "w") + if chapters_file == nil then + msg.error("could not open chapter file for writing") + return + end + + local success, error = chapters_file:write(construct_ffmetadata()) + chapters_file:close() + + if success then + if osd then + prompt_msg("Chapters written to:" .. chapters_file_path) + end + return chapters_file_path + else + msg.error("error writing chapters file:", error) + return + end +end + +function load_chapters() + local path = mp.get_property("path") + local expected_chapters_file = utils.join_path(utils.split_path(path), mp.get_property("filename") .. ".ffmetadata") + + msg.debug("looking for:", expected_chapters_file) + + local file = utils.file_info(expected_chapters_file) + + if file then + msg.debug("found in the local directory, loading..") + mp.set_property("file-local-options/chapters-file", expected_chapters_file) + chapter_state = "external-chapters" + return + end + + if not o.global_chapters then + msg.debug("not in local, global chapters not enabled, aborting search") + return + end + + msg.debug("looking in the global directory") + + if o.hash_global_chapters then + local hashed_path = hash() + if hashed_path then + expected_chapters_file = utils.join_path(o.global_chapters_path, hashed_path .. ".ffmetadata") + else + msg.debug("hash function failed, fallback to path") + expected_chapters_file = + utils.join_path(o.global_chapters_path, mp.get_property("filename") .. ".ffmetadata") + end + else + expected_chapters_file = utils.join_path(o.global_chapters_path, mp.get_property("filename") .. ".ffmetadata") + end + + msg.debug("looking for:", expected_chapters_file) + + file = utils.file_info(expected_chapters_file) + + if file then + msg.debug("found in the global directory, loading..") + mp.set_property("file-local-options/chapters-file", expected_chapters_file) + chapter_state = "external-chapters" + return + end + + msg.debug("chapters file not found") +end + +function bake_chapters() + if mp.get_property_number("chapter-list/count") == 0 then + msg.verbose("no chapters present") + return + end + + local chapters_file_path = write_chapters(false, true) + if not chapters_file_path then + msg.error("no chapters file") + return + end + + local filename = mp.get_property("filename") + local output_name + + -- extract file extension + local reverse_dot_index = filename:reverse():find(".", 1, true) + if reverse_dot_index == nil then + msg.warning("file has no extension, fallback to .mkv") + output_name = filename .. ".chapters.mkv" + else + local dot_index = #filename + 1 - reverse_dot_index + local ext = filename:sub(dot_index + 1) + msg.debug("ext:", ext) + if ext ~= "mkv" and ext ~= "mp4" and ext ~= "webm" then + msg.debug("fallback to .mkv") + ext = "mkv" + end + output_name = filename:sub(1, dot_index) .. "chapters." .. ext + end + + local file_path = mp.get_property("path") + local output_path = utils.join_path(utils.split_path(file_path), output_name) + + local args = { + "ffmpeg", + "-y", + "-i", + file_path, + "-i", + chapters_file_path, + "-map_metadata", + "1", + "-codec", + "copy", + output_path, + } + + msg.debug("args:", utils.to_string(args)) + + local process = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = args, + }) + + if process.status == 0 then + prompt_msg("file written to " .. output_path) + else + msg.error("failed to write file:\n", process.stderr) + end +end + +--modified fork of autoload script-- +function toggle_autoload() + if autoload_playlist == true then + prompt_msg("○ Auto-Load Disabled") + autoload_playlist = false + elseif autoload_playlist == false then + prompt_msg("● Auto-Load Enabled") + autoload_playlist = true + end + if autoload_playlist then + find_and_add_entries() + end +end + +function Set(t) + local set = {} + for _, v in pairs(t) do + set[v] = true + end + return set +end + +function SetUnion(a, b) + for k in pairs(b) do + a[k] = true + end + return a +end + +function Split(s) + local set = {} + for v in string.gmatch(s, "([^,]+)") do + set[v] = true + end + return set +end + +EXTENSIONS_VIDEO = Set({ + "3g2", + "3gp", + "avi", + "flv", + "m2ts", + "m4v", + "mj2", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogv", + "rmvb", + "webm", + "wmv", + "y4m", +}) + +EXTENSIONS_AUDIO = Set({ + "aiff", + "ape", + "au", + "flac", + "m4a", + "mka", + "mp3", + "oga", + "ogg", + "ogm", + "opus", + "wav", + "wma", +}) + +EXTENSIONS_IMAGES = Set({ + "avif", + "bmp", + "gif", + "j2k", + "jp2", + "jpeg", + "jpg", + "jxl", + "png", + "svg", + "tga", + "tif", + "tiff", + "webp", +}) + +function split_option_exts(video, audio, image) + if video then + o.additional_video_exts = Split(o.additional_video_exts) + end + if audio then + o.additional_audio_exts = Split(o.additional_audio_exts) + end + if image then + o.additional_image_exts = Split(o.additional_image_exts) + end +end +split_option_exts(true, true, true) + +function create_extensions() + EXTENSIONS = {} + if o.videos then + SetUnion(SetUnion(EXTENSIONS, EXTENSIONS_VIDEO), o.additional_video_exts) + end + if o.audio then + SetUnion(SetUnion(EXTENSIONS, EXTENSIONS_AUDIO), o.additional_audio_exts) + end + if o.images then + SetUnion(SetUnion(EXTENSIONS, EXTENSIONS_IMAGES), o.additional_image_exts) + end +end +create_extensions() + +function validate_directory_mode() + if o.directory_mode ~= "recursive" and o.directory_mode ~= "lazy" and o.directory_mode ~= "ignore" then + o.directory_mode = nil + end +end +validate_directory_mode() + +function add_files(files) + local oldcount = mp.get_property_number("playlist-count", 1) + for i = 1, #files do + mp.commandv("loadfile", files[i][1], "append") + mp.commandv("playlist-move", oldcount + i - 1, files[i][2]) + end +end + +function get_extension(path) + match = string.match(path, "%.([^%.]+)$") + if match == nil then + return "nomatch" + else + return match + end +end + +table.filter = function(t, iter) + for i = #t, 1, -1 do + if not iter(t[i]) then + table.remove(t, i) + end + end +end + +table.append = function(t1, t2) + local t1_size = #t1 + for i = 1, #t2 do + t1[t1_size + i] = t2[i] + end +end + +-- alphanum sorting for humans in Lua +-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua + +function alphanumsort(filenames) + local function padnum(n, d) + return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d)) or ("%03d%s"):format(#n, n) + end + + local tuples = {} + for i, f in ipairs(filenames) do + tuples[i] = { f:lower():gsub("0*(%d+)%.?(%d*)", padnum), f } + end + table.sort(tuples, function(a, b) + return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1] + end) + for i, tuple in ipairs(tuples) do + filenames[i] = tuple[2] + end + return filenames +end + +local autoloaded = nil +local added_entries = {} +local autoloaded_dir = nil + +function scan_dir(path, current_file, dir_mode, separator, dir_depth, total_files, extensions) + if dir_depth == o.autoload_max_dir_stack then + return + end + msg.trace("scanning: " .. path) + local files = utils.readdir(path, "files") or {} + local dirs = dir_mode ~= "ignore" and utils.readdir(path, "dirs") or {} + local prefix = path == "." and "" or path + table.filter(files, function(v) + -- The current file could be a hidden file, ignoring it doesn't load other + -- files from the current directory. + if o.ignore_hidden and not (prefix .. v == current_file) and string.match(v, "^%.") then + return false + end + local ext = get_extension(v) + if ext == nil then + return false + end + return extensions[string.lower(ext)] + end) + table.filter(dirs, function(d) + return not (o.ignore_hidden and string.match(d, "^%.")) + end) + alphanumsort(files) + alphanumsort(dirs) + + for i, file in ipairs(files) do + files[i] = prefix .. file + end + + table.append(total_files, files) + if dir_mode == "recursive" then + for _, dir in ipairs(dirs) do + scan_dir( + prefix .. dir .. separator, + current_file, + dir_mode, + separator, + dir_depth + 1, + total_files, + extensions + ) + end + else + for i, dir in ipairs(dirs) do + dirs[i] = prefix .. dir + end + table.append(total_files, dirs) + end +end + +function find_and_add_entries() + local path = mp.get_property("path", "") + local dir, filename = utils.split_path(path) + msg.trace(("dir: %s, filename: %s"):format(dir, filename)) + if not autoload_playlist then + msg.verbose("stopping: autoload_playlist is disabled") + return + elseif #dir == 0 then + msg.verbose("stopping: not a local path") + return + end + + local pl_count = mp.get_property_number("playlist-count", 1) + this_ext = get_extension(filename) + -- check if this is a manually made playlist + if (pl_count > 1 and autoloaded == nil) or (pl_count == 1 and EXTENSIONS[string.lower(this_ext)] == nil) then + msg.verbose("stopping: manually made playlist") + return + else + if pl_count == 1 then + autoloaded = true + autoloaded_dir = dir + added_entries = {} + end + end + + local extensions = {} + if o.same_type then + if EXTENSIONS_VIDEO[string.lower(this_ext)] ~= nil then + extensions = EXTENSIONS_VIDEO + elseif EXTENSIONS_AUDIO[string.lower(this_ext)] ~= nil then + extensions = EXTENSIONS_AUDIO + else + extensions = EXTENSIONS_IMAGES + end + else + extensions = EXTENSIONS + end + + local pl = mp.get_property_native("playlist", {}) + local pl_current = mp.get_property_number("playlist-pos-1", 1) + msg.trace(("playlist-pos-1: %s, playlist: %s"):format(pl_current, utils.to_string(pl))) + + local files = {} + do + local dir_mode = o.directory_mode or mp.get_property("directory-mode", "lazy") + local separator = mp.get_property_native("platform") == "windows" and "\\" or "/" + scan_dir(autoloaded_dir, path, dir_mode, separator, 0, files, extensions) + end + + if next(files) == nil then + msg.verbose("no other files or directories in directory") + return + end + + -- Find the current pl entry (dir+"/"+filename) in the sorted dir list + local current + for i = 1, #files do + if files[i] == path then + current = i + break + end + end + if current == nil then + return + end + msg.trace("current file position in files: " .. current) + + -- treat already existing playlist entries, independent of how they got added + -- as if they got added by autoload + for _, entry in ipairs(pl) do + added_entries[entry.filename] = true + end + + local append = { [-1] = {}, [1] = {} } + for direction = -1, 1, 2 do -- 2 iterations, with direction = -1 and +1 + for i = 1, o.autoload_max_entries do + local pos = current + i * direction + local file = files[pos] + if file == nil or file[1] == "." then + break + end + + -- skip files that are/were already in the playlist + if not added_entries[file] then + if direction == -1 then + msg.info("Prepending " .. file) + table.insert(append[-1], 1, { file, pl_current + i * direction + 1 }) + else + msg.info("Adding " .. file) + if pl_count > 1 then + table.insert(append[1], { file, pl_current + i * direction - 1 }) + else + mp.commandv("loadfile", file, "append") + end + end + end + added_entries[file] = true + end + if pl_count == 1 and direction == -1 and #append[-1] > 0 then + for i = 1, #append[-1] do + mp.commandv("loadfile", append[-1][i][1], "append") + end + mp.commandv("playlist-move", 0, current) + end + end + + if pl_count > 1 then + add_files(append[1]) + add_files(append[-1]) + end +end + +--modified fork of chapterskip.lua-- + +function matches(i, title) + local opt_skip = o.skip + if type(o.skip) == "table" then + for i = 1, #o.skip do + if o.skip[i] and o.skip[i][1] == chapter_state then + opt_skip = o.skip[i][2] + break + end + end + end + + for category in string.gmatch(opt_skip, " *([^;]*[^; ]) *") do + if categories[category:lower()] then + if category:lower() == "idx-" or category:lower() == "toggle_idx" then + for pattern in string.gmatch(categories[category:lower()], "([^/]+)") do + if tonumber(pattern) == i then + return true + end + end + else + if title then + for pattern in string.gmatch(categories[category:lower()], "([^/]+)") do + if string.match(title, pattern) then + return true + end + end + end + end + end + end +end + +local skipped = {} +local parsed = {} + +function prep_chapterskip_var() + if chapter_state == "no-chapters" then + return + end + g_opt_categories = o.categories + + g_opt_skip_once = false + if o.skip_once == true or o.skip_once == false then + g_opt_skip_once = o.skip_once + elseif has_value(o.skip_once, chapter_state) then + g_opt_skip_once = true + end + + if type(o.categories) == "table" then + for i = 1, #o.categories do + if o.categories[i] and o.categories[i][1] == chapter_state then + g_opt_categories = o.categories[i][2] + break + end + end + end + + for category in string.gmatch(g_opt_categories, "([^;]+)") do + local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)") + if name then + categories[name:lower()] = patterns + elseif not parsed[category] then + mp.msg.warn("Improper category definition: " .. category) + end + parsed[category] = true + end +end + +function start_chapterskip_countdown(text, duration) + g_autoskip_countdown_flag = true + g_autoskip_countdown = g_autoskip_countdown - 1 + + if o.autoskip_countdown_graceful and (g_autoskip_countdown <= 0) then + kill_chapterskip_countdown() + mp.osd_message("", 0) + return + end + + if g_autoskip_countdown < 0 then + kill_chapterskip_countdown() + mp.osd_message("", 0) + return + end + + text = text:gsub("%%countdown%%", g_autoskip_countdown) + prompt_msg(text, 2000) +end + +function kill_chapterskip_countdown(action) + if not g_autoskip_countdown_flag then + return + end + if action == "osd" and o.autoskip_osd ~= "no-osd" then + prompt_msg("○ Auto-Skip Cancelled") + end + if g_autoskip_timer ~= nil then + g_autoskip_timer:kill() + end + unbind_keys(o.cancel_autoskip_countdown_keybind, "cancel-autoskip-countdown") + unbind_keys(o.proceed_autoskip_countdown_keybind, "proceed-autoskip-countdown") + g_autoskip_countdown = o.autoskip_countdown + g_autoskip_countdown_flag = false +end + +function chapterskip(_, current, countdown) + if chapter_state == "no-chapters" then + return + end + if not autoskip_chapter then + return + end + if g_autoskip_countdown_flag then + kill_chapterskip_countdown("osd") + end + if not countdown then + countdown = o.autoskip_countdown + end + + local chapters = mp.get_property_native("chapter-list") + local skip = false + local consecutive_i = 0 + + for i = 0, #chapters do + if + (not g_opt_skip_once or not skipped[i]) + and i == 0 + and chapters[i + 1] + and matches(i, chapters[i + 1].title) + then + if i == current + 1 or skip == i - 1 then + if skip then + skipped[skip] = true + end + skip = i + consecutive_i = consecutive_i + 1 + end + elseif (not g_opt_skip_once or not skipped[i]) and chapters[i] and matches(i, chapters[i].title) then + if i == current + 1 or skip == i - 1 then + if skip then + skipped[skip] = true + end + skip = i + consecutive_i = consecutive_i + 1 + end + elseif skip and countdown <= 0 then + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(autoskip_osd, "show-progress") + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + + if o.autoskip_osd == "osd-msg-bar" or o.autoskip_osd == "osd-msg" then + if consecutive_i > 1 then + local autoskip_osd_string = "" + for j = consecutive_i, 1, -1 do + local chapter_title = "" + if chapters[i - j] then + chapter_title = chapters[i - j].title + end + autoskip_osd_string = ( + autoskip_osd_string + .. "\n ➤ Chapter (" + .. i - j + .. ") " + .. chapter_title + ) + end + prompt_msg("● Auto-Skip" .. autoskip_osd_string) + else + prompt_msg("➤ Auto-Skip: Chapter " .. mp.command_native({ "expand-text", "${chapter}" })) + end + end + mp.set_property("time-pos", chapters[i].time) + skipped[skip] = true + return + elseif skip and countdown > 0 then + g_autoskip_countdown_flag = true + bind_keys(o.cancel_autoskip_countdown_keybind, "cancel-autoskip-countdown", function() + kill_chapterskip_countdown("osd") + return + end) + + local autoskip_osd_string = "" + local autoskip_graceful_osd = "" + if o.autoskip_countdown_graceful then + autoskip_graceful_osd = "Press Keybind to:\n" + end + if o.autoskip_osd == "osd-msg-bar" or o.autoskip_osd == "osd-msg" then + if consecutive_i > 1 and o.autoskip_countdown_bulk then + local autoskip_osd_string = "" + for j = consecutive_i, 1, -1 do + local chapter_title = "" + if chapters[i - j] then + chapter_title = chapters[i - j].title + end + autoskip_osd_string = ( + autoskip_osd_string + .. "\n ▷ Chapter (" + .. i - j + .. ") " + .. chapter_title + ) + end + prompt_msg( + autoskip_graceful_osd + .. "○ Auto-Skip" + .. ' in "' + .. o.autoskip_countdown + .. '"' + .. autoskip_osd_string, + 2000 + ) + g_autoskip_timer = mp.add_periodic_timer(1, function() + start_chapterskip_countdown( + autoskip_graceful_osd .. "○ Auto-Skip" .. ' in "%countdown%"' .. autoskip_osd_string, + 2000 + ) + end) + else + prompt_msg( + autoskip_graceful_osd + .. '▷ Auto-Skip in "' + .. o.autoskip_countdown + .. '": Chapter ' + .. mp.command_native({ "expand-text", "${chapter}" }), + 2000 + ) + g_autoskip_timer = mp.add_periodic_timer(1, function() + start_chapterskip_countdown( + autoskip_graceful_osd + .. '▷ Auto-Skip in "%countdown%": Chapter ' + .. mp.command_native({ "expand-text", "${chapter}" }), + 2000 + ) + end) + end + end + function proceed_autoskip(force) + if not g_autoskip_countdown_flag then + kill_chapterskip_countdown() + return + end + if g_autoskip_countdown > 1 and not force then + return + end + + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(autoskip_osd, "show-progress") + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + if o.autoskip_osd == "osd-msg-bar" or o.autoskip_osd == "osd-msg" then + if consecutive_i > 1 and o.autoskip_countdown_bulk then + local autoskip_osd_string = "" + for j = consecutive_i, 1, -1 do + local chapter_title = "" + if chapters[i - j] then + chapter_title = chapters[i - j].title + end + autoskip_osd_string = ( + autoskip_osd_string + .. "\n ➤ Chapter (" + .. i - j + .. ") " + .. chapter_title + ) + end + prompt_msg("● Auto-Skip" .. autoskip_osd_string) + else + prompt_msg("➤ Auto-Skip: Chapter " .. mp.command_native({ "expand-text", "${chapter}" })) + end + end + if consecutive_i > 1 and o.autoskip_countdown_bulk then + mp.set_property("time-pos", chapters[i].time) + else + mp.commandv("no-osd", "add", "chapter", 1) + end + skipped[skip] = true + kill_chapterskip_countdown() + end + bind_keys(o.proceed_autoskip_countdown_keybind, "proceed-autoskip-countdown", function() + proceed_autoskip(true) + return + end) + if o.autoskip_countdown_graceful then + return + end + mp.add_timeout(countdown, proceed_autoskip) + return + end + end + if skip and countdown <= 0 then + if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then + return mp.set_property("time-pos", mp.get_property_native("duration")) + end + mp.commandv("playlist-next") + if o.autoskip_osd ~= "no-osd" then + autoskip_playlist_osd = true + end + elseif skip and countdown > 0 then + g_autoskip_countdown_flag = true + bind_keys(o.cancel_autoskip_countdown_keybind, "cancel-autoskip-countdown", function() + kill_chapterskip_countdown("osd") + return + end) + + if o.autoskip_osd == "osd-msg-bar" or o.autoskip_osd == "osd-msg" then + local autoskip_graceful_osd = "" + if o.autoskip_countdown_graceful then + autoskip_graceful_osd = "Press Keybind to:\n" + end + if consecutive_i > 1 and o.autoskip_countdown_bulk then + local i = (mp.get_property_number("chapters") + 1 or 0) + local autoskip_osd_string = "" + for j = consecutive_i, 1, -1 do + local chapter_title = "" + if chapters[i - j] then + chapter_title = chapters[i - j].title + end + autoskip_osd_string = (autoskip_osd_string .. "\n ▷ Chapter (" .. i - j .. ") " .. chapter_title) + end + prompt_msg( + autoskip_graceful_osd + .. "○ Auto-Skip" + .. ' in "' + .. o.autoskip_countdown + .. '"' + .. autoskip_osd_string, + 2000 + ) + g_autoskip_timer = mp.add_periodic_timer(1, function() + start_chapterskip_countdown( + autoskip_graceful_osd .. "○ Auto-Skip" .. ' in "%countdown%"' .. autoskip_osd_string, + 2000 + ) + end) + else + prompt_msg( + autoskip_graceful_osd + .. '▷ Auto-Skip in "' + .. o.autoskip_countdown + .. '": Chapter ' + .. mp.command_native({ "expand-text", "${chapter}" }), + 2000 + ) + g_autoskip_timer = mp.add_periodic_timer(1, function() + start_chapterskip_countdown( + autoskip_graceful_osd + .. '▷ Auto-Skip in "%countdown%": Chapter ' + .. mp.command_native({ "expand-text", "${chapter}" }), + 2000 + ) + end) + end + end + function proceed_autoskip(force) + if not g_autoskip_countdown_flag then + return + end + if g_autoskip_countdown > 1 and not force then + return + end + + mp.set_property("osd-duration", o.osd_duration) + mp.commandv(autoskip_osd, "show-progress") + mp.add_timeout(0.07, function() + mp.set_property("osd-duration", osd_duration_default) + end) + if consecutive_i > 1 and o.autoskip_countdown_bulk then + if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then + return mp.set_property("time-pos", mp.get_property_native("duration")) + end + mp.commandv("playlist-next") + else + local current_chapter = (mp.get_property_number("chapter") + 1 or 0) + local chapters_count = (mp.get_property_number("chapters") or 0) + + if current_chapter == chapters_count then + if mp.get_property_native("playlist-count") == mp.get_property_native("playlist-pos-1") then + return mp.set_property("time-pos", mp.get_property_native("duration")) + end + mp.commandv("playlist-next") + else + mp.commandv("no-osd", "add", "chapter", 1) + end + end + if o.autoskip_osd ~= "no-osd" then + autoskip_playlist_osd = true + end + kill_chapterskip_countdown() + end + bind_keys(o.proceed_autoskip_countdown_keybind, "proceed-autoskip-countdown", function() + proceed_autoskip(true) + return + end) + if o.autoskip_countdown_graceful then + return + end + mp.add_timeout(countdown, proceed_autoskip) + end +end + +function toggle_autoskip() + if autoskip_chapter == true then + prompt_msg("○ Auto-Skip Disabled") + autoskip_chapter = false + if g_autoskip_countdown_flag then + kill_chapterskip_countdown() + end + elseif autoskip_chapter == false then + prompt_msg("● Auto-Skip Enabled") + autoskip_chapter = true + end +end + +function toggle_category_autoskip() + if chapter_state == "no-chapters" then + return + end + if not mp.get_property_number("chapter") then + return + end + local chapters = mp.get_property_native("chapter-list") + local current_chapter = (mp.get_property_number("chapter") + 1 or 0) + + local chapter_title = tostring(current_chapter) + if current_chapter > 0 and chapters[current_chapter].title and chapters[current_chapter].title ~= "" then + chapter_title = chapters[current_chapter].title + end + + local found_i = 0 + if matches(current_chapter, chapter_title) then + for category in string.gmatch(g_opt_categories, "([^;]+)") do + local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)") + + for pattern in string.gmatch(patterns, "([^/]+)") do + if string.match(chapter_title:lower(), pattern:lower()) then + g_opt_categories = g_opt_categories:gsub(esc_string(pattern) .. "/?", "") + found_i = found_i + 1 + end + end + end + + for category in string.gmatch(g_opt_categories, "([^;]+)") do + local name, patterns = string.match(category, " *([^+>]*[^+> ]) *[+>](.*)") + if name then + categories[name:lower()] = patterns + elseif not parsed[category] then + mp.msg.warn("Improper category definition: " .. category) + end + parsed[category] = true + end + + if type(o.categories) == "table" then + for i = 1, #o.categories do + if o.categories[i] and o.categories[i][1] == chapter_state then + o.categories[i][2] = g_opt_categories + break + end + end + else + o.categories = g_opt_categories + end + end + if current_chapter > 0 and chapters[current_chapter].title and chapters[current_chapter].title ~= "" then + if found_i > 0 or string.match(categories.toggle, esc_string(chapter_title)) then + prompt_msg("○ Removed from Auto-Skip\n ▷ Chapter: " .. chapter_title) + categories.toggle = categories.toggle:gsub(esc_string("^" .. chapter_title .. "/"), "") + if g_autoskip_countdown_flag then + kill_chapterskip_countdown() + end + else + prompt_msg("● Added to Auto-Skip\n ➔ Chapter: " .. chapter_title) + categories.toggle = categories.toggle .. "^" .. chapter_title .. "/" + end + else + if found_i > 0 or string.match(categories.toggle_idx, esc_string(chapter_title)) then + prompt_msg("○ Removed from Auto-Skip\n ▷ Chapter: " .. chapter_title) + categories.toggle_idx = categories.toggle_idx:gsub(esc_string(chapter_title .. "/"), "") + if g_autoskip_countdown_flag then + kill_chapterskip_countdown() + end + else + prompt_msg("● Added to Auto-Skip\n ➔ Chapter: " .. chapter_title) + categories.toggle_idx = categories.toggle_idx .. chapter_title .. "/" + end + end +end + +-- HOOKS -------------------------------------------------------------------- +if user_input_module then + mp.add_hook("on_unload", 50, function() + input.cancel_user_input() + end) +end -- chapters.lua +mp.register_event("start-file", find_and_add_entries) -- autoload.lua +mp.observe_property("chapter", "number", chapterskip) -- chapterskip.lua + +-- smart skip events / properties / hooks -- + +mp.register_event("file-loaded", function() + file_length = (mp.get_property_native("duration") or 0) + if o.playlist_osd and g_playlist_pos > 0 then + playlist_osd = true + end + if playlist_osd and not autoskip_playlist_osd then + prompt_msg( + "[" + .. mp.command_native({ "expand-text", "${playlist-pos-1}" }) + .. "/" + .. mp.command_native({ "expand-text", "${playlist-count}" }) + .. "] " + .. mp.command_native({ "expand-text", "${filename}" }) + ) + end + if autoskip_playlist_osd then + prompt_msg( + "➤ Auto-Skip\n[" + .. mp.command_native({ "expand-text", "${playlist-pos-1}" }) + .. "/" + .. mp.command_native({ "expand-text", "${playlist-count}" }) + .. "] " + .. mp.command_native({ "expand-text", "${filename}" }) + ) + end + playlist_osd = false + autoskip_playlist_osd = false + force_silence_skip = false + skipped = {} + initial_chapter_count = mp.get_property_number("chapter-list/count") + if initial_chapter_count > 0 and chapter_state ~= "external-chapters" then + chapter_state = "internal-chapters" + end + prep_chapterskip_var() +end) + +mp.add_hook("on_load", 50, function() + if o.external_chapters_autoload then + load_chapters() + end +end) + +mp.observe_property("pause", "bool", function(name, value) + if value and skip_flag then + restoreProp(initial_skip_time, true) + end + if g_autoskip_countdown_flag then + kill_chapterskip_countdown("osd") + end +end) + +mp.add_hook("on_unload", 9, function() + if o.modified_chapters_autosave == true or has_value(o.modified_chapters_autosave, chapter_state) then + write_chapters(false) + end + mp.set_property("keep-open", keep_open_state) + chapter_state = "no-chapters" + g_playlist_pos = (mp.get_property_native("playlist-playing-pos") + 1 or 0) + kill_chapterskip_countdown() +end) + +mp.register_event("seek", function() + if g_autoskip_countdown_flag then + kill_chapterskip_countdown("osd") + end +end) + +mp.observe_property("eof-reached", "bool", eofHandler) + +-- BINDINGS -------------------------------------------------------------------- + +bind_keys(o.toggle_autoload_keybind, "toggle-autoload", toggle_autoload) +bind_keys(o.toggle_autoskip_keybind, "toggle-autoskip", toggle_autoskip) +bind_keys(o.toggle_category_autoskip_keybind, "toggle-category-autoskip", toggle_category_autoskip) +bind_keys(o.add_chapter_keybind, "add-chapter", add_chapter) +bind_keys(o.remove_chapter_keybind, "remove-chapter", remove_chapter) +bind_keys(o.write_chapters_keybind, "write-chapters", function() + write_chapters(true) +end) +bind_keys(o.edit_chapter_keybind, "edit-chapter", edit_chapter) +bind_keys(o.bake_chapters_keybind, "bake-chapters", bake_chapters) +bind_keys(o.chapter_prev_keybind, "chapter-prev", function() + chapterSeek(-1) +end) +bind_keys(o.chapter_next_keybind, "chapter-next", function() + chapterSeek(1) +end) +bind_keys(o.smart_prev_keybind, "smart-prev", smartPrev) +bind_keys(o.smart_next_keybind, "smart-next", smartNext) +bind_keys(o.silence_skip_keybind, "silence-skip", silenceSkip) diff --git a/mac/.config/mpv/scripts/UndoRedo.lua b/mac/.config/mpv/scripts/UndoRedo.lua new file mode 100644 index 0000000..67d3f2b --- /dev/null +++ b/mac/.config/mpv/scripts/UndoRedo.lua @@ -0,0 +1,212 @@ +-- Copyright (c) 2021, Eisa AlAwadhi +-- License: BSD 2-Clause License + +-- Creator: Eisa AlAwadhi +-- Project: UndoRedo +-- Version: 2.2 + +local utils = require("mp.utils") +local msg = require("mp.msg") +local seconds = 0 +local countTimer = -1 +local seekTime = 0 +local seekNumber = 0 +local currentIndex = 0 +local seekTable = {} +local seeking = 0 +local undoRedo = 0 +local pause = false +seekTable[0] = 0 + +----------------------------USER CUSTOMIZATION SETTINGS----------------------------------- +--These settings are for users to manually change some options in the script. +--Keybinds can be defined in the bottom of the script. + +local osd_messages = true --true is for displaying osd messages when actions occur, Change to false will disable all osd messages generated from this script + +---------------------------END OF USER CUSTOMIZATION SETTINGS--------------------- + +local function prepareUndoRedo() + if pause == true then + seconds = seconds + else + seconds = seconds - 0.5 + end + seekTable[currentIndex] = seekTable[currentIndex] + seconds + seconds = 0 +end + +local function getUndoRedo() + if seeking == 0 then + prepareUndoRedo() + + seekNumber = currentIndex + 1 + currentIndex = seekNumber + seekTime = math.floor(mp.get_property_number("time-pos")) + table.insert(seekTable, seekNumber, seekTime) + + undoRedo = 0 + elseif seeking == 1 then + seeking = 0 + end +end + +mp.register_event("file-loaded", function() + filePath = mp.get_property("path") + + timer = mp.add_periodic_timer(0.1, function() + seconds = seconds + 0.1 + end) + + if pause == true then + timer:stop() + else + timer:resume() + end + + timer2 = mp.add_periodic_timer(0.1, function() + countTimer = countTimer + 0.1 + + if countTimer == 0.6 then + timer:resume() + getUndoRedo() + end + end) + + timer2:stop() +end) + +mp.register_event("seek", function() + countTimer = 0 + timer2:resume() + timer:stop() +end) + +mp.observe_property("pause", "bool", function(name, value) + if value then + if timer ~= nil then + timer:stop() + end + pause = true + else + if timer ~= nil then + timer:resume() + end + pause = false + end +end) + +mp.register_event("end-file", function() + if timer ~= nil then + timer:kill() + end + if timer2 ~= nil then + timer2:kill() + end + seekNumber = 0 + currentIndex = 0 + undoRedo = 0 + seconds = 0 + countTimer = -1 + seekTable[0] = 0 +end) + +local function undo() + if (filePath ~= nil) and (countTimer >= 0) and (countTimer < 0.6) and (seeking == 0) then + timer2:stop() + + getUndoRedo() + + currentIndex = currentIndex - 1 + if currentIndex < 0 then + if osd_messages == true then + mp.osd_message("No Undo Found") + end + currentIndex = 0 + msg.info("No undo found") + else + if seekTable[currentIndex] < 0 then + seekTable[currentIndex] = 0 + end + + seeking = 1 + + mp.commandv("seek", seekTable[currentIndex], "absolute", "exact") + + undoRedo = 1 + if osd_messages == true then + mp.osd_message("Undo") + end + msg.info("Seeked using undo") + end + elseif (filePath ~= nil) and (currentIndex > 0) then + prepareUndoRedo() + currentIndex = currentIndex - 1 + + if seekTable[currentIndex] < 0 then + seekTable[currentIndex] = 0 + end + + seeking = 1 + mp.commandv("seek", seekTable[currentIndex], "absolute", "exact") + + undoRedo = 1 + if osd_messages == true then + mp.osd_message("Undo") + end + msg.info("Seeked using undo") + elseif (filePath ~= nil) and (currentIndex == 0) then + if osd_messages == true then + mp.osd_message("No Undo Found") + end + msg.info("No undo found") + end +end + +local function redo() + if (filePath ~= nil) and (currentIndex < seekNumber) then + prepareUndoRedo() + currentIndex = currentIndex + 1 + + if seekTable[currentIndex] < 0 then + seekTable[currentIndex] = 0 + end + + seeking = 1 + mp.commandv("seek", seekTable[currentIndex], "absolute", "exact") + + undoRedo = 0 + + if osd_messages == true then + mp.osd_message("Redo") + end + msg.info("Seeked using redo") + elseif (filePath ~= nil) and (currentIndex == seekNumber) then + if osd_messages == true then + mp.osd_message("No Redo Found") + end + msg.info("No redo found") + end +end + +local function undoLoop() + if (filePath ~= nil) and (undoRedo == 0) then + undo() + elseif (filePath ~= nil) and (undoRedo == 1) then + redo() + elseif (filePath ~= nil) and (countTimer == -1) then + if osd_messages == true then + mp.osd_message("No Undo Found") + end + msg.info("No undo found") + end +end + +mp.add_key_binding("ctrl+z", "undo", undo) +mp.add_key_binding("ctrl+Z", "undoCaps", undo) + +mp.add_key_binding("ctrl+y", "redo", redo) +mp.add_key_binding("ctrl+Y", "redoCaps", redo) + +mp.add_key_binding("ctrl+alt+z", "undoLoop", undoLoop) +mp.add_key_binding("ctrl+alt+Z", "undoLoopCaps", undoLoop) diff --git a/mac/.config/mpv/scripts/blackout.lua b/mac/.config/mpv/scripts/blackout.lua new file mode 100644 index 0000000..419f34d --- /dev/null +++ b/mac/.config/mpv/scripts/blackout.lua @@ -0,0 +1,73 @@ +--[[ + blackout / by sibwaf / https://github.com/sibwaf/mpv-scripts + + Turns the screen completely black and pauses on a button press ([b] by default) + so you can hide whatever you are watching from other people fast enough. + Unpause or press the same button to go back. + + May not work if your VO driver doesn't support changing contrast. + + MIT license - do whatever you want, but I'm not responsible for any possible problems. + Please keep the URL to the original repository. Thanks! +]] + +--[[ + Configuration: + + # property + + Theoratically, can be any of: "brightness", "contrast", "saturation", "gamma", "hue". + In practice, "contrast" seems to work best. Setting it to "saturation" or "hue" + is pretty much pointless as you will get gray-scale or a colored negative video. +]] + +local property = "contrast" + +---------- + +local watch_later_options_default = mp.get_property_native("watch-later-options") +local watch_later_options_blackout = {} + +for _, option in pairs(watch_later_options_default) do + if option == property then + -- remove + elseif option == "sid" then + -- remove + else + table.insert(watch_later_options_blackout, option) + end +end + +local saved_value = nil +local saved_sid = nil + +function toggle_blackout() + if saved_value then + mp.set_property(property, saved_value) + saved_value = nil + + mp.set_property("sid", saved_sid) + saved_sid = nil + + mp.set_property_native("watch-later-options", watch_later_options_default) + else + mp.set_property("pause", "yes") + + saved_value = mp.get_property(property) + mp.set_property(property, -100) + + saved_sid = mp.get_property("sid") + mp.set_property("sid", "no") + + mp.set_property_native("watch-later-options", watch_later_options_blackout) + end +end + +function on_pause_change(name, value) + if not value and saved_value then + toggle_blackout() + end +end + +mp.add_key_binding("b", "blackout", toggle_blackout) +mp.observe_property("pause", "bool", on_pause_change) diff --git a/mac/.config/mpv/scripts/blur-edges.lua b/mac/.config/mpv/scripts/blur-edges.lua new file mode 100644 index 0000000..6c3a111 --- /dev/null +++ b/mac/.config/mpv/scripts/blur-edges.lua @@ -0,0 +1,174 @@ +local options = require("mp.options") + +local opts = { + blur_radius = 10, + blur_power = 10, + minimum_black_bar_size = 3, + mode = "all", + active = true, + reapply_delay = 0.5, + watch_later_fix = false, + only_fullscreen = true, +} +options.read_options(opts) + +local active = opts.active +local applied = false + +function set_lavfi_complex(filter) + if not filter and mp.get_property("lavfi-complex") == "" then + return + end + local force_window = mp.get_property("force-window") + local sub = mp.get_property("sub") + mp.set_property("force-window", "yes") + if not filter then + mp.set_property("lavfi-complex", "") + mp.set_property("vid", "1") + else + if not opts.watch_later_fix then + mp.set_property("vid", "no") + end + mp.set_property("lavfi-complex", filter) + end + mp.set_property("sub", "no") + mp.set_property("force-window", force_window) + mp.set_property("sub", sub) +end + +function set_blur() + if applied then + return + end + if not mp.get_property("video-out-params") then + return + end + if opts.only_fullscreen and not mp.get_property_bool("fullscreen") then + return + end + local video_aspect = mp.get_property_number("video-aspect-override") + local ww, wh = mp.get_osd_size() + + if math.abs(ww / wh - video_aspect) < 0.05 then + return + end + if opts.mode == "horizontal" and ww / wh < video_aspect then + return + end + if opts.mode == "vertical" and ww / wh > video_aspect then + return + end + + local par = mp.get_property_number("video-params/par") + local height = mp.get_property_number("video-params/h") + local width = mp.get_property_number("video-params/w") + + local split = "[vid1] split=3 [a] [v] [b]" + local crop_format = "crop=%s:%s:%s:%s" + local scale_format = "scale=width=%s:height=%s:flags=neighbor" + + local stack_direction, cropped_scaled_1, cropped_scaled_2, blur_size + + if ww / wh > video_aspect then + blur_size = math.floor((ww / wh) * height / par - width) + local nudge = blur_size % 2 + blur_size = blur_size / 2 + + local height_with_maximized_width = height / width * ww + local visible_height = math.floor(height * par * wh / height_with_maximized_width) + local visible_width = math.floor(blur_size * wh / height_with_maximized_width) + + local cropped_1 = string.format(crop_format, visible_width, visible_height, "0", (height - visible_height) / 2) + local scaled_1 = string.format(scale_format, blur_size + nudge, height) + cropped_scaled_1 = cropped_1 .. "," .. scaled_1 + + local cropped_2 = string.format( + crop_format, + visible_width, + visible_height, + width - visible_width, + (height - visible_height) / 2 + ) + local scaled_2 = string.format(scale_format, blur_size, height) + cropped_scaled_2 = cropped_2 .. "," .. scaled_2 + stack_direction = "h" + else + blur_size = math.floor((wh / ww) * width * par - height) + local nudge = blur_size % 2 + blur_size = blur_size / 2 + + local width_with_maximized_height = width / height * wh + local visible_width = math.floor(width * ww / width_with_maximized_height) + local visible_height = math.floor(blur_size * ww / width_with_maximized_height) + + local cropped_1 = string.format(crop_format, visible_width, visible_height, (width - visible_width) / 2, "0") + local scaled_1 = string.format(scale_format, width, blur_size + nudge) + cropped_scaled_1 = cropped_1 .. "," .. scaled_1 + + local cropped_2 = string.format( + crop_format, + visible_width, + visible_height, + (width - visible_width) / 2, + height - visible_height + ) + local scaled_2 = string.format(scale_format, width, blur_size) + cropped_scaled_2 = cropped_2 .. "," .. scaled_2 + stack_direction = "v" + end + + if blur_size < math.max(1, opts.minimum_black_bar_size) then + return + end + local lr = math.min(opts.blur_radius, math.floor(blur_size / 2) - 1) + local cr = math.min(opts.blur_radius, math.floor(blur_size / 4) - 1) + local blur = string.format("boxblur=lr=%i:lp=%i:cr=%i:cp=%i", lr, opts.blur_power, cr, opts.blur_power) + + zone_1 = string.format("[a] %s,%s [a_fin]", cropped_scaled_1, blur) + zone_2 = string.format("[b] %s,%s [b_fin]", cropped_scaled_2, blur) + + local par_fix = "setsar=ratio=" .. tostring(par) .. ":max=10000" + + stack = string.format("[a_fin] [v] [b_fin] %sstack=3,%s [vo]", stack_direction, par_fix) + filter = string.format("%s;%s;%s;%s", split, zone_1, zone_2, stack) + set_lavfi_complex(filter) + applied = true +end + +function unset_blur() + set_lavfi_complex() + applied = false +end + +local reapplication_timer = mp.add_timeout(opts.reapply_delay, set_blur) +reapplication_timer:kill() + +function reset_blur(k, v) + unset_blur() + reapplication_timer:kill() + reapplication_timer:resume() +end + +function toggle() + if active then + active = false + unset_blur() + mp.unobserve_property(reset_blur) + else + active = true + set_blur() + local properties = { "osd-width", "osd-height", "path", "fullscreen" } + for _, p in ipairs(properties) do + mp.observe_property(p, "native", reset_blur) + end + end +end + +if active then + active = false + toggle() +end + +mp.add_key_binding(nil, "toggle-blur", toggle) +mp.add_key_binding(nil, "set-blur", set_blur) +mp.add_key_binding(nil, "unset-blur", unset_blur) diff --git a/mac/.config/mpv/scripts/change-OSD-media-title.lua b/mac/.config/mpv/scripts/change-OSD-media-title.lua new file mode 100644 index 0000000..51701e1 --- /dev/null +++ b/mac/.config/mpv/scripts/change-OSD-media-title.lua @@ -0,0 +1,30 @@ +function set_osd_title() + local name = mp.get_property_osd("filename") + local percent_pos = "" + local chapter = "" + local playlist_num = "" + local frames_dropped = "" + + if mp.get_property_osd("playlist-count") ~= "1" then + playlist_num = "[" + .. mp.get_property_osd("playlist-pos-1") + .. "/" + .. mp.get_property_osd("playlist-count") + .. "] " + end + if mp.get_property_osd("chapter") ~= "" then + chapter = mp.get_property_osd("chapter") .. " | " + end + + if mp.get_property_osd("percent-pos") ~= "" then + if mp.get_property_osd("percent-pos") ~= "100" then + percent_pos = " [ " .. mp.get_property_osd("percent-pos") .. "% completed ]" + mp.set_property("force-media-title", playlist_num .. chapter .. name .. percent_pos) + else + mp.set_property("force-media-title", playlist_num .. chapter .. name) + end + end +end + +mp.observe_property("percent-pos", "number", set_osd_title) +mp.observe_property("chapter", "string", set_osd_title) diff --git a/mac/.config/mpv/scripts/command_palette.lua b/mac/.config/mpv/scripts/command_palette.lua new file mode 100644 index 0000000..adbd330 --- /dev/null +++ b/mac/.config/mpv/scripts/command_palette.lua @@ -0,0 +1,1229 @@ +-- https://github.com/stax76/mpv-scripts + +----- options + +local o = { + font_size = 16, + scale_by_window = false, + lines_to_show = 12, + pause_on_open = false, -- does not work on my system when enabled, menu won't show + resume_on_exit = "only-if-was-paused", + + -- styles + line_bottom_margin = 1, + menu_x_padding = 5, + menu_y_padding = 2, + + use_mediainfo = false, -- # true requires the MediaInfo CLI app being installed + stream_quality_options = "2160,1440,1080,720,480", + aspect_ratios = "4:3,16:9,2.35:1,1.36,1.82,0,-1", +} + +local opt = require("mp.options") +opt.read_options(o) + +----- string + +function is_empty(input) + if input == nil or input == "" then + return true + end +end + +function contains(input, find) + if not is_empty(input) and not is_empty(find) then + return input:find(find, 1, true) + end +end + +function starts_with(str, start) + return str:sub(1, #start) == start +end + +function split(input, sep) + assert(#sep == 1) -- supports only single character separator + local tbl = {} + + if input ~= nil then + for str in string.gmatch(input, "([^" .. sep .. "]+)") do + table.insert(tbl, str) + end + end + + return tbl +end + +function replace(str, what, with) + what = string.gsub(what, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1") + with = string.gsub(with, "[%%]", "%%%%") + return string.gsub(str, what, with) +end + +function first_to_upper(str) + return (str:gsub("^%l", string.upper)) +end + +----- list + +function list_contains(list, value) + for _, v in pairs(list) do + if v == value then + return true + end + end +end + +----- path + +function get_temp_dir() + local is_windows = package.config:sub(1, 1) == "\\" + + if is_windows then + return os.getenv("TEMP") .. "\\" + else + return "/tmp/" + end +end + +---- file + +function file_exists(path) + if is_empty(path) then + return false + end + local file = io.open(path, "r") + + if file ~= nil then + io.close(file) + return true + end +end + +function file_write(path, content) + local file = assert(io.open(path, "w")) + file:write(content) + file:close() +end + +----- mpv + +local utils = require("mp.utils") +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") + +----- path mpv + +function file_name(value) + local _, filename = utils.split_path(value) + return filename +end + +----- main + +local command_palette_version = 2 +local BluRayTitles = {} +local dpiScale = 0 +local originalFontSize = o.font_size + +mp.commandv("script-message", "command-palette-version", command_palette_version) + +local is_older_than_v0_36 = string.find(mp.get_property("mpv-version"), "mpv v0%.[1-3][0-5]%.") == 1 + +if not is_older_than_v0_36 then + mp.set_property_native("user-data/command-palette/version", command_palette_version) +end + +mp.enable_messages("info") + +mp.register_event("log-message", function(e) + if e.prefix ~= "bd" then + return + end + + if contains(e.text, " 0 duration: ") then + BluRayTitles = {} + end + + if contains(e.text, " duration: ") then + local match = string.match(e.text, "%d%d:%d%d:%d%d") + + if match then + table.insert(BluRayTitles, match) + end + end +end) + +local uosc_available = false +package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path + +local em = require("extended-menu") +local menu = em:new(o) +local menu_content = { list = {}, current_i = nil } +local media_info_cache = {} +local original_set_active_func = em.set_active +local original_get_line_func = em.get_line + +function em:get_bindings() + local bindings = { + { + "esc", + function() + self:set_active(false) + end, + }, + { + "enter", + function() + self:handle_enter() + end, + }, + { + "bs", + function() + self:handle_backspace() + end, + }, + { + "del", + function() + self:handle_del() + end, + }, + { + "ins", + function() + self:handle_ins() + end, + }, + { + "left", + function() + self:prev_char() + end, + }, + { + "right", + function() + self:next_char() + end, + }, + { + "ctrl+f", + function() + self:next_char() + end, + }, + { + "up", + function() + self:change_selected_index(-1) + end, + }, + { + "down", + function() + self:change_selected_index(1) + end, + }, + { + "ctrl+up", + function() + self:move_history(-1) + end, + }, + { + "ctrl+down", + function() + self:move_history(1) + end, + }, + { + "ctrl+left", + function() + self:prev_word() + end, + }, + { + "ctrl+right", + function() + self:next_word() + end, + }, + { + "home", + function() + self:go_home() + end, + }, + { + "end", + function() + self:go_end() + end, + }, + { + "pgup", + function() + self:change_selected_index(-o.lines_to_show) + end, + }, + { + "pgdwn", + function() + self:change_selected_index(o.lines_to_show) + end, + }, + { + "ctrl+u", + function() + self:del_to_start() + end, + }, + { + "ctrl+v", + function() + self:paste(true) + end, + }, + { + "ctrl+bs", + function() + self:del_word() + end, + }, + { + "ctrl+del", + function() + self:del_next_word() + end, + }, + { + "kp_dec", + function() + self:handle_char_input(".") + end, + }, + { + "mbtn_left", + function() + self:handle_enter() + end, + }, + { + "mbtn_right", + function() + self:set_active(false) + end, + }, + { + "wheel_up", + function() + self:change_selected_index(-1) + end, + }, + { + "wheel_down", + function() + self:change_selected_index(1) + end, + }, + { + "mbtn_forward", + function() + self:change_selected_index(-o.lines_to_show) + end, + }, + { + "mbtn_back", + function() + self:change_selected_index(o.lines_to_show) + end, + }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = { + "kp" .. i, + function() + self:handle_char_input("" .. i) + end, + } + end + + return bindings +end + +function em:set_active(active) + original_set_active_func(self, active) + + if not active then + if osc_visibility == "auto" or osc_visibility == "always" then + mp.command("script-message osc-visibility " .. osc_visibility .. " no_osd") + osc_visibility = nil + elseif uosc_available then + mp.commandv("script-message-to", "uosc", "disable-elements", mp.get_script_name(), "") + end + end +end + +menu.index_field = "index" + +local function format_time(t, duration) + local h = math.floor(t / (60 * 60)) + t = t - (h * 60 * 60) + local m = math.floor(t / 60) + local s = t - (m * 60) + + if duration >= 60 * 60 or h > 0 then + return string.format("%.2d:%.2d:%.2d", h, m, s) + end + + return string.format("%.2d:%.2d", m, s) +end + +function get_media_info() + local path = mp.get_property("path") + + if contains(path, "://") or not file_exists(path) then + return + end + + if media_info_cache[path] then + return media_info_cache[path] + end + + local format_file = get_temp_dir() .. mp.get_script_name() .. " media-info-format-v1.txt" + + if not file_exists(format_file) then + media_info_format = + [[General;N: %FileNameExtension%\\nG: %Format%, %FileSize/String%, %Duration/String%, %OverallBitRate/String%, %Recorded_Date%\\n +Video;V: %Format%, %Format_Profile%, %Width%x%Height%, %BitRate/String%, %FrameRate% FPS\\n +Audio;A: %Language/String%, %Format%, %Format_Profile%, %BitRate/String%, %Channel(s)% ch, %SamplingRate/String%, %Title%\\n +Text;S: %Language/String%, %Format%, %Format_Profile%, %Title%\\n]] + + file_write(format_file, media_info_format) + end + + local proc_result = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = { "mediainfo", "--inform=file://" .. format_file, path }, + }) + + if proc_result.status == 0 then + local output = proc_result.stdout + + output = string.gsub(output, ", , ,", ",") + output = string.gsub(output, ", ,", ",") + output = string.gsub(output, ": , ", ": ") + output = string.gsub(output, ", \\n\r*\n", "\\n") + output = string.gsub(output, "\\n\r*\n", "\\n") + output = string.gsub(output, ", \\n", "\\n") + output = string.gsub(output, "\\n", "\n") + output = string.gsub(output, "%.000 FPS", " FPS") + output = string.gsub(output, "MPEG Audio, Layer 3", "MP3") + + media_info_cache[path] = output + + return output + end +end + +function binding_get_line(self, _, v) + local ass = assdraw.ass_new() + local cmd = self:ass_escape(v.cmd) + local key = self:ass_escape(v.key) + local comment = self:ass_escape(v.comment or "") + + if v.priority == -1 or v.priority == -2 then + local why_inactive = (v.priority == -1) and "Inactive" or "Shadowed" + ass:append(self:get_font_color("comment")) + + if comment ~= "" then + ass:append(comment .. "\\h") + end + + ass:append(key .. "\\h(" .. why_inactive .. ")" .. "\\h" .. cmd) + return ass.text + end + + if comment ~= "" then + ass:append(self:get_font_color("default")) + ass:append(comment .. "\\h") + end + + ass:append(self:get_font_color("accent")) + ass:append(key) + ass:append(self:get_font_color("comment")) + ass:append(" " .. cmd) + return ass.text +end + +function command_palette_get_line(self, _, v) + local ass = assdraw.ass_new() + + if v.key == "" then + ass:append(self:get_font_color("default")) + ass:append(self:ass_escape(v.name or "")) + else + ass:append(self:get_font_color("default")) + ass:append(self:ass_escape(v.name or "") .. "\\h") + + ass:append(self:get_font_color("accent")) + ass:append(self:ass_escape("(" .. v.key .. ")")) + end + + return ass.text +end + +local function format_flags(track) + local flags = "" + + for _, flag in ipairs({ + "default", + "forced", + "dependent", + "visual-impaired", + "hearing-impaired", + "image", + "external", + }) do + if track[flag] then + flags = flags .. flag .. " " + end + end + + if flags == "" then + return "" + end + + return " [" .. flags:sub(1, -2) .. "]" +end + +local function fix_codec(value) + if contains(value, "hdmv_pgs_subtitle") then + value = replace(value, "hdmv_pgs_subtitle", "pgs") + end + + return value:upper() +end + +local function get_language(lng) + if lng == nil or lng == "" then + return lng + end + + if lng == "ara" then + lng = "Arabic" + end + if lng == "ben" then + lng = "Bangla" + end + if lng == "bng" then + lng = "Bangla" + end + if lng == "chi" then + lng = "Chinese" + end + if lng == "zho" then + lng = "Chinese" + end + if lng == "eng" then + lng = "English" + end + if lng == "fre" then + lng = "French" + end + if lng == "fra" then + lng = "French" + end + if lng == "ger" then + lng = "German" + end + if lng == "deu" then + lng = "German" + end + if lng == "hin" then + lng = "Hindi" + end + if lng == "ita" then + lng = "Italian" + end + if lng == "jpn" then + lng = "Japanese" + end + if lng == "kor" then + lng = "Korean" + end + if lng == "msa" then + lng = "Malay" + end + if lng == "por" then + lng = "Portuguese" + end + if lng == "pan" then + lng = "Punjabi" + end + if lng == "rus" then + lng = "Russian" + end + if lng == "spa" then + lng = "Spanish" + end + if lng == "und" then + lng = "Undetermined" + end + + return lng +end + +local function format_track(track) + local lng = get_language(track.lang) + return (track.selected and "●" or "○") + .. ( + (lng and lng .. " " or "") + .. fix_codec(track.codec and track.codec .. " " or "") + .. (track["demux-w"] and track["demux-w"] .. "x" .. track["demux-h"] .. " " or "") + .. (track["demux-fps"] and not track.image and string.format("%.4f", track["demux-fps"]):gsub("%.?0*$", "") .. " fps " or "") + .. (track["demux-channel-count"] and track["demux-channel-count"] .. "ch " or "") + .. (track["codec-profile"] and track.type == "audio" and track["codec-profile"] .. " " or "") + .. (track["demux-samplerate"] and track["demux-samplerate"] / 1000 .. " kHz " or "") + .. (track["demux-bitrate"] and string.format("%.0f", track["demux-bitrate"] / 1000) .. " kbps " or "") + .. (track["hls-bitrate"] and string.format("%.0f", track["hls-bitrate"] / 1000) .. " HLS kbps " or "") + ):sub(1, -2) + .. format_flags(track) + .. (track.title and " " .. track.title or "") +end + +local function select(conf) + for k, v in ipairs(conf.items) do + table.insert(menu_content.list, { index = k, content = v }) + end + + if conf.default_item then + menu_content.current_i = conf.default_item + end + + function menu:submit(value) + conf.submit(value) + end +end + +local function select_track(property, type, error) + local tracks = {} + local items = {} + local default_item + local track_id = mp.get_property_native(property) + + for _, track in ipairs(mp.get_property_native("track-list")) do + if track.type == type then + tracks[#tracks + 1] = track + items[#items + 1] = format_track(track) + + if track.id == track_id then + default_item = #items + end + end + end + + if #items == 0 then + mp.commandv("show-text", error) + return + end + + select({ + items = items, + default_item = default_item, + submit = function(tbl) + mp.command("set " .. property .. " " .. (tracks[tbl.index].selected and "no" or tracks[tbl.index].id)) + end, + }) +end + +function hide_osc() + if is_empty(mp.get_property("path")) and not is_older_than_v0_36 then + osc_visibility = mp.get_property_native("user-data/osc/visibility") + + if osc_visibility == "auto" or osc_visibility == "always" then + mp.command("script-message osc-visibility never no_osd") + end + end + + if uosc_available then + local disable_elements = + "window_border, top_bar, timeline, controls, volume, idle_indicator, audio_indicator, buffering_indicator, pause_indicator" + mp.commandv("script-message-to", "uosc", "disable-elements", mp.get_script_name(), disable_elements) + end +end + +mp.register_script_message("show-command-palette", function(name) + if dpiScale == 0 then + dpiScale = mp.get_property_native("display-hidpi-scale", 1) + end + + o.font_size = originalFontSize * dpiScale + + menu_content.list = {} + menu_content.current_i = 1 + menu.search_heading = name + menu.filter_by_fields = { "content" } + em.get_line = original_get_line_func + + if name == "Command Palette" then + local menu_items = {} + local bindings = utils.parse_json(mp.get_property("input-bindings")) + + local items = { + "Playlist", + "Tracks", + "Video Tracks", + "Audio Tracks", + "Subtitle Tracks", + "Secondary Subtitle", + "Subtitle Line", + "Chapters", + "Profiles", + "Bindings", + "Commands", + "Properties", + "Options", + "Audio Devices", + "Blu-ray Titles", + "Stream Quality", + "Aspect Ratio", + "Command Palette", + "Recent Files", + } + + for _, item in ipairs(items) do + local found = false + + for _, binding in ipairs(bindings) do + if + contains(binding.cmd, "show-command-palette") + and (contains(binding.cmd, '"' .. item .. '"') or contains(binding.cmd, "'" .. item .. "'")) + then + table.insert(menu_items, { name = item, key = binding.key, cmd = binding.cmd }) + found = true + break + end + end + + if not found then + local cmd = "script-message-to command_palette show-command-palette '" .. item .. "'" + table.insert(menu_items, { name = item, key = "", cmd = cmd }) + end + end + + menu_content.list = menu_items + + function menu:submit(tbl) + mp.command(tbl.cmd) + end + + menu.filter_by_fields = { "name", "key" } + em.get_line = command_palette_get_line + elseif name == "Bindings" then + local bindings = utils.parse_json(mp.get_property("input-bindings")) + + for _, v in ipairs(bindings) do + v.key = "(" .. v.key .. ")" + + if not is_empty(v.comment) then + if contains(v.comment, "custom-menu: ") then + v.comment = replace(v.comment, "custom-menu: ", "") + end + + if contains(v.comment, "menu: ") then + v.comment = replace(v.comment, "menu: ", "") + end + + v.comment = first_to_upper(v.comment) + end + end + + for _, v in ipairs(bindings) do + for _, v2 in ipairs(bindings) do + if v.key == v2.key and v.priority < v2.priority then + v.priority = -2 + break + end + end + end + + table.sort(bindings, function(i, j) + return i.priority > j.priority + end) + + menu_content.list = bindings + + function menu:submit(tbl) + mp.command(tbl.cmd) + end + + menu.filter_by_fields = { "cmd", "key", "comment" } + em.get_line = binding_get_line + elseif name == "Chapters" then + local default_index = mp.get_property_native("chapter") + + if not default_index then + mp.commandv("show-text", "Chapter: (unavailable)") + return + end + + local duration = mp.get_property_native("duration", math.huge) + + for i, chapter in ipairs(mp.get_property_native("chapter-list")) do + table.insert( + menu_content.list, + { index = i, content = format_time(chapter.time, duration) .. " " .. chapter.title } + ) + end + + menu_content.current_i = default_index + 1 + + function menu:submit(tbl) + mp.set_property_number("chapter", tbl.index - 1) + end + elseif name == "Playlist" then + local count = mp.get_property_number("playlist-count") + if count == 0 then + return + end + + for i = 0, (count - 1) do + local text = mp.get_property("playlist/" .. i .. "/title") + + if text == nil then + text = file_name(mp.get_property("playlist/" .. i .. "/filename")) + end + + table.insert(menu_content.list, { index = i + 1, content = text }) + end + + menu_content.current_i = mp.get_property_number("playlist-pos") + 1 + + function menu:submit(tbl) + mp.set_property_number("playlist-pos", tbl.index - 1) + end + elseif name == "Commands" then + local commands = utils.parse_json(mp.get_property("command-list")) + + for k, v in ipairs(commands) do + local text = v.name + + for _, arg in ipairs(v.args) do + if arg.optional then + text = text .. " [<" .. arg.name .. ">]" + else + text = text .. " <" .. arg.name .. ">" + end + end + + table.insert(menu_content.list, { index = k, content = text }) + end + + function menu:submit(tbl) + print(tbl.content) + local cmd = string.match(tbl.content, "%S+") + mp.commandv("script-message-to", "console", "type", cmd .. " ") + end + elseif name == "Properties" then + local properties = split(mp.get_property("property-list"), ",") + + for k, v in ipairs(properties) do + table.insert(menu_content.list, { index = k, content = v }) + end + + function menu:submit(tbl) + mp.commandv("script-message-to", "console", "type", "print-text ${" .. tbl.content .. "}") + end + elseif name == "Options" then + local options = split(mp.get_property("options"), ",") + + for k, v in ipairs(options) do + local type = mp.get_property_osd("option-info/" .. v .. "/type", "") + local default = mp.get_property_osd("option-info/" .. v .. "/default-value", "") + v = v .. " (type: " .. type .. ", default: " .. default .. ")" + table.insert(menu_content.list, { index = k, content = v }) + end + + function menu:submit(tbl) + print(tbl.content) + local prop = string.match(tbl.content, "%S+") + mp.commandv("script-message-to", "console", "type", "set " .. prop .. " ") + end + elseif name == "Profiles" then + local profiles = utils.parse_json(mp.get_property("profile-list")) + local ignore_list = { "builtin-pseudo-gui", "encoding", "libmpv", "pseudo-gui", "default" } + + for k, v in ipairs(profiles) do + if not list_contains(ignore_list, v.name) then + table.insert(menu_content.list, { index = k, content = v.name }) + end + end + + function menu:submit(tbl) + mp.command("show-text " .. tbl.content) + mp.command("apply-profile " .. tbl.content) + end + elseif name == "Audio Devices" then + local devices = utils.parse_json(mp.get_property("audio-device-list")) + local current_name = mp.get_property("audio-device") + + for k, v in ipairs(devices) do + table.insert(menu_content.list, { index = k, name = v.name, content = v.description }) + + if v.name == current_name then + menu_content.current_i = k + end + end + + function menu:submit(tbl) + mp.commandv("set", "audio-device", tbl.name) + mp.commandv("show-text", "audio-device: " .. tbl.content) + end + elseif name == "Aspect Ratio" then + local current_ar = mp.get_property_number("video-aspect-override") + + for k, v in ipairs(split(o.aspect_ratios, ",")) do + local display_name = v + + if display_name == "0" then + display_name = "0 (square pixels)" + end + if display_name == "-1" then + display_name = "-1 (original)" + end + + table.insert(menu_content.list, { index = k, content = display_name, value = v }) + + local w, h = string.match(v, "^([0-9.]+):([0-9.]+)$") + + if w and h then + local current_ar_truncated = tonumber(string.format("%.3f", current_ar)) + local ar_truncated = tonumber(string.format("%.3f", w / h)) + + if current_ar_truncated == ar_truncated then + menu_content.current_i = k + end + elseif v == tostring(current_ar) then + menu_content.current_i = k + end + end + + function menu:submit(tbl) + mp.command("set video-aspect-override " .. tbl.value) + end + elseif name == "Stream Quality" then + local ytdl_format = mp.get_property_native("ytdl-format") + + for k, v in ipairs(split(o.stream_quality_options, ",")) do + local format = "bestvideo[height<=?" .. v .. "]+bestaudio/best[height<=?" .. v .. "]" + table.insert(menu_content.list, { index = k, content = v .. "p", value = format }) + + if format == ytdl_format then + menu_content.current_i = k + end + end + + function menu:submit(tbl) + mp.set_property("ytdl-format", tbl.value) + mp.commandv("show-text", "Stream Quality: " .. tbl.content) + + local duration = mp.get_property_native("duration") + local time_pos = mp.get_property("time-pos") + + mp.command("playlist-play-index current") + + if duration and duration > 0 then + local function seeker() + mp.commandv("seek", time_pos, "absolute") + mp.unregister_event(seeker) + end + + mp.register_event("file-loaded", seeker) + end + end + elseif name == "Tracks" then + local tracks = {} + + for i, track in ipairs(mp.get_property_native("track-list")) do + local type = track.image and "I" or track.type + + if type == "video" then + type = "V" + end + if type == "audio" then + type = "A" + end + if type == "sub" then + type = "S" + end + + tracks[i] = type .. ": " .. format_track(track) + end + + if #tracks == 0 then + mp.commandv("show-text", "No available tracks") + return + end + + select({ + items = tracks, + submit = function(tbl) + local track = mp.get_property_native("track-list/" .. tbl.index - 1) + + if track then + mp.command("set " .. track.type .. " " .. (track.selected and "no" or track.id)) + end + end, + }) + elseif name == "Audio Tracks" then + if o.use_mediainfo then + local mi = get_media_info() + if mi == nil then + return + end + local tracks = split(mi .. "\nA: None", "\n") + local id = 0 + + for _, v in ipairs(tracks) do + if starts_with(v, "A: ") then + id = id + 1 + table.insert(menu_content.list, { index = id, content = string.sub(v, 4) }) + end + end + + menu_content.current_i = mp.get_property_number("aid") or id + + function menu:submit(tbl) + mp.command("set aid " .. ((tbl.index == id) and "no" or tbl.index)) + end + else + select_track("aid", "audio", "No available audio tracks") + end + elseif name == "Subtitle Tracks" then + if o.use_mediainfo then + local mi = get_media_info() + if mi == nil then + return + end + local tracks = split(mi .. "\nS: None", "\n") + local id = 0 + + for _, v in ipairs(tracks) do + if starts_with(v, "S: ") then + id = id + 1 + table.insert(menu_content.list, { index = id, content = string.sub(v, 4) }) + end + end + + menu_content.current_i = mp.get_property_number("sid") or id + + function menu:submit(tbl) + mp.command("set sid " .. ((tbl.index == id) and "no" or tbl.index)) + end + else + select_track("sid", "sub", "No available subtitle tracks") + end + elseif name == "Secondary Subtitle" then + select_track("secondary-sid", "sub", "No available subtitle tracks") + elseif name == "Recent Files" then + local frontend = mp.get_property_native("user-data/frontend/name") + + if frontend == "mpv.net" then + mp.command("script-message show-recent-in-command-palette") + else + mp.command("script-message open-recent-menu command-palette") + end + + return + elseif name == "Video Tracks" then + if o.use_mediainfo then + local mi = get_media_info() + if mi == nil then + return + end + local tracks = split(mi .. "\nV: None", "\n") + local id = 0 + + for _, v in ipairs(tracks) do + if starts_with(v, "V: ") then + id = id + 1 + table.insert(menu_content.list, { index = id, content = string.sub(v, 4) }) + end + end + + menu_content.current_i = mp.get_property_number("vid") or id + + function menu:submit(tbl) + mp.command("set vid " .. ((tbl.index == id) and "no" or tbl.index)) + end + else + select_track("vid", "video", "No available video tracks") + end + elseif name == "Blu-ray Titles" then + if #BluRayTitles == 0 then + return + end + + local items = {} + + for k, v in ipairs(BluRayTitles) do + table.insert(items, "Title " .. k .. " " .. v) + end + + select({ + items = items, + submit = function(tbl) + mp.commandv("loadfile", "bd://" .. (tbl.index - 1)) + end, + }) + elseif name == "Subtitle Line" then + local sub = mp.get_property_native("current-tracks/sub") + + if sub == nil then + mp.commandv("show-text", "No subtitle is loaded") + return + end + + if sub.external and sub["external-filename"]:find("^edl://") then + sub["external-filename"] = sub["external-filename"]:match("https?://.*") or sub["external-filename"] + end + + local r = mp.command_native({ + name = "subprocess", + capture_stdout = true, + args = sub.external and { + "ffmpeg", + "-loglevel", + "error", + "-i", + sub["external-filename"], + "-f", + "lrc", + "-map_metadata", + "-1", + "-fflags", + "+bitexact", + "-", + } or { + "ffmpeg", + "-loglevel", + "error", + "-i", + mp.get_property("path"), + "-map", + "s:" .. sub["id"] - 1, + "-f", + "lrc", + "-map_metadata", + "-1", + "-fflags", + "+bitexact", + "-", + }, + }) + + if r.error_string == "init" then + mp.commandv("show-text", "Failed to extract subtitles: ffmpeg not found") + return + elseif r.status ~= 0 then + mp.commandv("show-text", "Failed to extract subtitles") + return + end + + local sub_lines = {} + local sub_times = {} + local default_item + local delay = mp.get_property_native("sub-delay") + local time_pos = mp.get_property_native("time-pos") - delay + local duration = mp.get_property_native("duration", math.huge) + + -- Strip HTML and ASS tags. + for line in r.stdout:gsub("<.->", ""):gsub("{\\.-}", ""):gmatch("[^\n]+") do + -- ffmpeg outputs LRCs with minutes > 60 instead of adding hours. + sub_times[#sub_times + 1] = line:match("%d+") * 60 + line:match(":([%d%.]*)") + sub_lines[#sub_lines + 1] = format_time(sub_times[#sub_times], duration) .. " " .. line:gsub(".*]", "", 1) + + if sub_times[#sub_times] <= time_pos then + default_item = #sub_times + end + end + + select({ + items = sub_lines, + default_item = default_item, + submit = function(tbl) + -- Add an offset to seek to the correct line while paused without a video track. + if mp.get_property_native("current-tracks/video/image") ~= false then + delay = delay + 0.1 + end + + mp.commandv("seek", sub_times[tbl.index] + delay, "absolute") + end, + }) + else + if name == nil then + msg.error("Unknown mode") + else + msg.error("Unknown mode: " .. name) + end + + return + end + + hide_osc() + menu:init(menu_content) +end) + +mp.register_script_message("uosc-version", function(version) + local major, minor = version:match("^(%d+)%.(%d+)") + if major and minor and tonumber(major) >= 5 and tonumber(minor) >= 0 then + uosc_available = true + end +end) + +mp.register_script_message("show-command-palette-json", function(json) + if dpiScale == 0 then + dpiScale = mp.get_property_native("display-hidpi-scale", 1) + end + + o.font_size = originalFontSize * dpiScale + + local menu_data = utils.parse_json(json) + menu_content.list = {} + menu_content.current_i = 1 + menu.search_heading = menu_data.title + menu.filter_by_fields = { "content", "hint", "value_hint" } + em.get_line = original_get_line_func + + for k, v in ipairs(menu_data.items) do + local values = v.value + + if type(values) == "string" then + values = { values } + end + + table.insert(menu_content.list, { + index = k, + content = v.title, + hint = v.hint, + values = values, + value_hint = table.concat(values, " "), + }) + + if menu_data.selected_index then + menu_content.current_i = menu_data.selected_index + end + end + + function menu:submit(tbl) + mp.command_native(tbl.values) + end + + hide_osc() + menu:init(menu_content) +end) diff --git a/mac/.config/mpv/scripts/cycle-video-rotate.lua b/mac/.config/mpv/scripts/cycle-video-rotate.lua new file mode 100644 index 0000000..e7be9e2 --- /dev/null +++ b/mac/.config/mpv/scripts/cycle-video-rotate.lua @@ -0,0 +1,36 @@ +-- ----------------------------------------------------------- +-- +-- CYCLE-VIDEO-ROTATE.LUA +-- Version: 1.0 +-- Author: VideoPlayerCode +-- URL: https://github.com/VideoPlayerCode/mpv-tools +-- +-- Description: +-- +-- Allows you to perform video rotation which perfectly +-- cycles through all 360 degrees without any glitches. +-- +-- ----------------------------------------------------------- + +function cycle_video_rotate(amt) + -- Ensure that amount is a base 10 integer. + amt = tonumber(amt, 10) + if amt == nil then + mp.osd_message("Rotate: Invalid rotation amount") + return nil -- abort + end + + -- Calculate what the next rotation value should be, + -- and wrap value to correct range (0 (aka 360) to 359). + local newrotate = mp.get_property_number("video-rotate") + newrotate = (newrotate + amt) % 360 + + -- Change rotation and tell the user. + mp.set_property_number("video-rotate", newrotate) + mp.osd_message("Rotate: " .. newrotate) +end + +-- Bind this via input.conf. Example: +-- Alt+LEFT script-message Cycle_Video_Rotate -90 +-- Alt+RIGHT script-message Cycle_Video_Rotate 90 +mp.register_script_message("cycle_video_rotate", cycle_video_rotate) diff --git a/mac/.config/mpv/scripts/delete_current_file.lua b/mac/.config/mpv/scripts/delete_current_file.lua new file mode 100644 index 0000000..972f681 --- /dev/null +++ b/mac/.config/mpv/scripts/delete_current_file.lua @@ -0,0 +1,168 @@ +--[[ + + https://github.com/stax76/mpv-scripts + + This script instantly deletes the file that is currently playing + via keyboard shortcut, the file is moved to the recycle bin and + removed from the playlist. + + On Linux the app trash-cli must be installed first. + + Usage: + Add bindings to input.conf: + + # delete directly + KP0 script-message-to delete_current_file delete-file + + # delete with confirmation + KP0 script-message-to delete_current_file delete-file KP1 "Press 1 to delete file" + + Press KP0 to initiate the delete operation, + the script will ask to confirm by pressing KP1. + You may customize the the init and confirm key and the confirm message. + Confirm key and confirm message are optional. + + Similar scripts: + https://github.com/zenyd/mpv-scripts#delete-file + +]] +-- + +key_bindings = {} + +function file_exists(name) + if not name or name == "" then + return false + end + + local f = io.open(name, "r") + + if f ~= nil then + io.close(f) + return true + else + return false + end +end + +function is_protocol(path) + return type(path) == "string" and (path:match("^%a[%a%d_-]+://")) +end + +function delete_file(path) + local is_windows = package.config:sub(1, 1) == "\\" + + if is_protocol(path) or not file_exists(path) then + return + end + + if is_windows then + local ps_code = [[ + Add-Type -AssemblyName Microsoft.VisualBasic + [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('__path__', 'OnlyErrorDialogs', 'SendToRecycleBin') + ]] + + local escaped_path = string.gsub(path, "'", "''") + escaped_path = string.gsub(escaped_path, "’", "’’") + escaped_path = string.gsub(escaped_path, "%%", "%%%%") + ps_code = string.gsub(ps_code, "__path__", escaped_path) + + mp.command_native({ + name = "subprocess", + playback_only = false, + detach = true, + args = { "powershell", "-NoProfile", "-Command", ps_code }, + }) + else + mp.command_native({ + name = "subprocess", + playback_only = false, + args = { "trash-put", path }, + }) + end +end + +function remove_current_file() + local count = mp.get_property_number("playlist-count") + local pos = mp.get_property_number("playlist-pos") + local new_pos = 0 + + if pos == count - 1 then + new_pos = pos - 1 + else + new_pos = pos + 1 + end + + mp.set_property_number("playlist-pos", new_pos) + + if pos > -1 then + mp.command("playlist-remove " .. pos) + end +end + +function handle_confirm_key() + local path = mp.get_property("path") + + if file_to_delete == path then + mp.commandv("show-text", "") + delete_file(file_to_delete) + remove_current_file() + remove_bindings() + file_to_delete = "" + end +end + +function cleanup() + remove_bindings() + file_to_delete = "" + mp.commandv("show-text", "") +end + +function get_bindings() + return { + { confirm_key, handle_confirm_key }, + } +end + +function add_bindings() + if #key_bindings > 0 then + return + end + + local script_name = mp.get_script_name() + + for _, bind in ipairs(get_bindings()) do + local name = script_name .. "_key_" .. (#key_bindings + 1) + key_bindings[#key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2]) + end +end + +function remove_bindings() + if #key_bindings == 0 then + return + end + + for _, name in ipairs(key_bindings) do + mp.remove_key_binding(name) + end + + key_bindings = {} +end + +function client_message(event) + local path = mp.get_property("path") + + if event.args[1] == "delete-file" and #event.args == 1 then + delete_file(path) + remove_current_file() + elseif event.args[1] == "delete-file" and #event.args == 3 and #key_bindings == 0 then + confirm_key = event.args[2] + mp.add_timeout(10, cleanup) + add_bindings() + file_to_delete = path + mp.commandv("show-text", event.args[3], "10000") + end +end + +mp.register_event("client-message", client_message) diff --git a/mac/.config/mpv/scripts/fuzzydir.lua b/mac/.config/mpv/scripts/fuzzydir.lua new file mode 100644 index 0000000..22119da --- /dev/null +++ b/mac/.config/mpv/scripts/fuzzydir.lua @@ -0,0 +1,278 @@ +--[[ + fuzzydir / by sibwaf / https://github.com/sibwaf/mpv-scripts + + Allows using "**" wildcards in sub-file-paths and audio-file-paths + so you don't have to specify all the possible directory names. + + Basically, allows you to do this and never have the need to edit any paths ever again: + audio-file-paths = ** + sub-file-paths = ** + + MIT license - do whatever you want, but I'm not responsible for any possible problems. + Please keep the URL to the original repository. Thanks! +]] + +--[[ + Configuration: + + # max_search_depth + + Determines the max depth of recursive search, should be >= 1 + + Examples for "sub-file-paths = **": + "max_search_depth = 1" => mpv will be able to find [xyz.ass, subs/xyz.ass] + "max_search_depth = 2" => mpv will be able to find [xyz.ass, subs/xyz.ass, subs/moresubs/xyz.ass] + + Please be careful when setting this value too high as it can result in awful performance or even stack overflow + + + # discovery_threshold + + fuzzydir will skip paths which contain more than discovery_threshold directories in them + + This is done to keep at least some garbage from getting into *-file-paths properties in case of big collections: + - dir1 <- will be ignored on opening video.mp4 as it's probably unrelated to the file + - ... + - dir999 <- will be ignored + - video.mp4 + + Use 0 to disable this behavior completely + + + # use_powershell + + fuzzydir will use PowerShell to traverse directories when it's available + + Can be faster in some cases, but can also be significantly slower +]] + +local max_search_depth = 3 +local discovery_threshold = 10 +local use_powershell = false + +---------- + +local utils = require("mp.utils") +local msg = require("mp.msg") + +local default_audio_paths = mp.get_property_native("options/audio-file-paths") +local default_sub_paths = mp.get_property_native("options/sub-file-paths") + +function foreach(list, action) + for _, item in pairs(list) do + action(item) + end +end + +function starts_with(str, prefix) + return string.sub(str, 1, string.len(prefix)) == prefix +end + +function ends_with(str, suffix) + return suffix == "" or string.sub(str, -string.len(suffix)) == suffix +end + +function add_all(to, from) + for index, element in pairs(from) do + table.insert(to, element) + end +end + +function contains(t, e) + for index, element in pairs(t) do + if element == e then + return true + end + end + return false +end + +function normalize(path) + if path == "." then + return "" + end + + if starts_with(path, "./") or starts_with(path, ".\\") then + path = string.sub(path, 3, -1) + end + if ends_with(path, "/") or ends_with(path, "\\") then + path = string.sub(path, 1, -2) + end + + return path +end + +function call_command(command) + local command_string = "" + for _, part in pairs(command) do + command_string = command_string .. part .. " " + end + + msg.trace("Calling external command:", command_string) + + local process = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = command, + }) + + if process.status ~= 0 then + msg.verbose("External command failed with status " .. process.status .. ": " .. command_string) + if process.stderr ~= "" then + msg.debug(process.stderr) + end + + return nil + end + + local result = {} + for line in string.gmatch(process.stdout, "([^\r\n]+)") do + table.insert(result, line) + end + return result +end + +-- Platform-dependent optimization + +local powershell_version = nil +if use_powershell then + powershell_version = call_command({ + "powershell", + "-NoProfile", + "-Command", + "$Host.Version.Major", + }) +end +if powershell_version ~= nil then + powershell_version = tonumber(powershell_version[1]) +end +if powershell_version == nil then + powershell_version = -1 +end +msg.debug("PowerShell version", powershell_version) + +function fast_readdir(path) + if powershell_version >= 3 then + msg.trace("Scanning", path, "with PowerShell") + result = call_command({ + "powershell", + "-NoProfile", + "-Command", + [[ + $dirs = Get-ChildItem -LiteralPath ]] .. string.format("%q", path) .. [[ -Directory + foreach($dir in $dirs) { + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($dir.Name) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + Write-Host "" + } ]], + }) + msg.trace("Finished scanning", path, "with PowerShell") + return result + end + + msg.trace("Scanning", path, "with default readdir") + result = utils.readdir(path, "dirs") + msg.trace("Finished scanning", path, "with default readdir") + return result +end + +-- Platform-dependent optimization end + +function traverse(search_path, current_path, level, cache) + local full_path = utils.join_path(search_path, current_path) + + if level > max_search_depth then + msg.trace("Traversed too deep, skipping scan for", full_path) + return {} + end + + if cache[full_path] ~= nil then + msg.trace("Returning from cache for", full_path) + return cache[full_path] + end + + local result = {} + + local discovered_paths = fast_readdir(full_path) + if discovered_paths == nil then + -- noop + msg.debug("Unable to scan " .. full_path .. ", skipping") + elseif discovery_threshold > 0 and #discovered_paths > discovery_threshold then + -- noop + msg.debug("Too many directories in " .. full_path .. ", skipping") + else + for _, discovered_path in pairs(discovered_paths) do + local new_path = utils.join_path(current_path, discovered_path) + + table.insert(result, new_path) + add_all(result, traverse(search_path, new_path, level + 1, cache)) + end + end + + cache[full_path] = result + + return result +end + +function explode(raw_paths, search_path, cache) + local result = {} + for _, raw_path in pairs(raw_paths) do + local parent, leftover = utils.split_path(raw_path) + if leftover == "**" then + msg.trace("Expanding wildcard for", raw_path) + table.insert(result, parent) + add_all(result, traverse(search_path, parent, 1, cache)) + else + msg.trace("Path", raw_path, "doesn't have a wildcard, keeping as-is") + table.insert(result, raw_path) + end + end + + local normalized = {} + for index, path in pairs(result) do + local normalized_path = normalize(path) + if not contains(normalized, normalized_path) and normalized_path ~= "" then + table.insert(normalized, normalized_path) + end + end + + return normalized +end + +function explode_all() + msg.debug("max_search_depth = " .. max_search_depth .. ", discovery_threshold = " .. discovery_threshold) + + local video_path = mp.get_property("path") + local search_path, _ = utils.split_path(video_path) + msg.debug("search_path = " .. search_path) + + local cache = {} + + foreach(default_audio_paths, function(it) + msg.debug("audio-file-paths:", it) + end) + local audio_paths = explode(default_audio_paths, search_path, cache) + foreach(audio_paths, function(it) + msg.debug("Adding to audio-file-paths:", it) + end) + mp.set_property_native("options/audio-file-paths", audio_paths) + + msg.verbose("Done expanding audio-file-paths") + + foreach(default_sub_paths, function(it) + msg.debug("sub-file-paths:", it) + end) + local sub_paths = explode(default_sub_paths, search_path, cache) + foreach(sub_paths, function(it) + msg.debug("Adding to sub-file-paths:", it) + end) + mp.set_property_native("options/sub-file-paths", sub_paths) + + msg.verbose("Done expanding sub-file-paths") + + msg.debug("Done expanding paths") +end + +mp.add_hook("on_load", 50, explode_all) diff --git a/mac/.config/mpv/scripts/gallery-thumbgen.lua b/mac/.config/mpv/scripts/gallery-thumbgen.lua new file mode 100644 index 0000000..dc0db1a --- /dev/null +++ b/mac/.config/mpv/scripts/gallery-thumbgen.lua @@ -0,0 +1,342 @@ +--[[ +mpv-gallery-view | https://github.com/occivink/mpv-gallery-view + +This mpv script implements a worker for generating gallery thumbnails. +It is meant to be used by other scripts. +Multiple copies of this script can be loaded by mpv. + +File placement: inside scripts directory +Settings: script-opts/gallery_worker.conf +]] + +local utils = require("mp.utils") +local msg = require("mp.msg") + +local jobs_queue = {} -- queue of thumbnail jobs +local failed = {} -- list of failed output paths, to avoid redoing them +local script_id = mp.get_script_name() .. utils.getpid() + +local opts = { + ytdl_exclude = "", +}; +(require("mp.options")).read_options(opts, "gallery_worker") + +local ytdl = { + path = "youtube-dl", + searched = false, + blacklisted = {}, -- Add patterns of URLs you want blacklisted from youtube-dl, + -- see gallery_worker.conf or ytdl_hook-exclude in the mpv manpage for more info +} + +function append_table(lhs, rhs) + for i = 1, #rhs do + lhs[#lhs + 1] = rhs[i] + end + return lhs +end + +local function file_exists(path) + local info = utils.file_info(path) + return info ~= nil and info.is_file +end + +local video_extensions = { "mkv", "webm", "mp4", "avi", "wmv" } + +function is_video(input_path) + local extension = string.match(input_path, "%.([^.]+)$") + if extension then + extension = string.lower(extension) + for _, ext in ipairs(video_extensions) do + if extension == ext then + return true + end + end + end + return false +end + +function is_blacklisted(url) + if opts.ytdl_exclude == "" then + return false + end + if #ytdl.blacklisted == 0 then + local joined = opts.ytdl_exclude + while joined:match("%|?[^|]+") do + local _, e, substring = joined:find("%|?([^|]+)") + table.insert(ytdl.blacklisted, substring) + joined = joined:sub(e + 1) + end + end + if #ytdl.blacklisted > 0 then + url = url:match("https?://(.+)") + for _, exclude in ipairs(ytdl.blacklisted) do + if url:match(exclude) then + msg.verbose("URL matches excluded substring. Skipping.") + return true + end + end + end + return false +end + +function ytdl_thumbnail_url(input_path) + local function exec(args) + local ret = utils.subprocess({ args = args, cancellable = false }) + return ret.status, ret.stdout, ret + end + local function first_non_nil(x, ...) + if x ~= nil then + return x + end + return first_non_nil(...) + end + + -- if input_path is youtube, generate our own URL + youtube_id1 = string.match(input_path, "https?://youtu%.be/([%a%d%-_]+).*") + youtube_id2 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/v/([%a%d%-_]+).*") + youtube_id3 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/watch%?v=([%a%d%-_]+).*") + youtube_id4 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/embed/([%a%d%-_]+).*") + youtube_id = youtube_id1 or youtube_id2 or youtube_id3 or youtube_id4 + + if youtube_id then + -- the hqdefault.jpg thumbnail should always exist, since it's used on the search result page + return "https://i.ytimg.com/vi/" .. youtube_id .. "/hqdefault.jpg" + end + + --otherwise proceed with the slower `youtube-dl -J` method + if not ytdl.searched then --search for youtude-dl in mpv's config directory + local exesuf = (package.config:sub(1, 1) == "\\") and ".exe" or "" + local ytdl_mcd = mp.find_config_file("youtube-dl") + if not (ytdl_mcd == nil) then + msg.error("found youtube-dl at: " .. ytdl_mcd) + ytdl.path = ytdl_mcd + end + ytdl.searched = true + end + local command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", input_path } + local es, json, result = exec(command) + + if (es < 0) or (json == nil) or (json == "") then + msg.error("fetching thumbnail url with youtube-dl failed for" .. input_path) + return input_path + end + local json, err = utils.parse_json(json) + if json == nil then + msg.error("failed to parse json for youtube-dl thumbnail: " .. err) + return input_path + end + + if (json.thumbnail == nil) or (json.thumbnail == "") then + msg.error("no thumbnail url from youtube-dl.") + return input_path + end + return json.thumbnail +end + +function thumbnail_command(input_path, width, height, take_thumbnail_at, output_path, accurate, with_mpv) + local vf = string.format( + "%s,%s", + string.format("scale=iw*min(1\\,min(%d/iw\\,%d/ih)):-2", width, height), + string.format("pad=%d:%d:(%d-iw)/2:(%d-ih)/2:color=0x00000000", width, height, width, height) + ) + local out = {} + local add = function(table) + out = append_table(out, table) + end + + if input_path:find("^https?://") and not is_blacklisted(input_path) then + -- returns the original input_path on failure + input_path = ytdl_thumbnail_url(input_path) + end + + if input_path:find("^archive://") or input_path:find("^edl://") then + with_mpv = true + end + + if not with_mpv then + out = { "ffmpeg" } + if is_video(input_path) then + if string.sub(take_thumbnail_at, -1) == "%" then + --if only fucking ffmpeg supported percent-style seeking + local res = utils.subprocess({ + args = { + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + input_path, + }, + cancellable = false, + }) + if res.status == 0 then + local duration = tonumber(string.match(res.stdout, "^%s*(.-)%s*$")) + if duration then + local percent = tonumber(string.sub(take_thumbnail_at, 1, -2)) + local start = tostring(duration * percent / 100) + add({ "-ss", start }) + end + end + else + add({ "-ss", take_thumbnail_at }) + end + end + if not accurate then + add({ "-noaccurate_seek" }) + end + add({ + "-i", + input_path, + "-vf", + vf, + "-map", + "v:0", + "-f", + "rawvideo", + "-pix_fmt", + "bgra", + "-c:v", + "rawvideo", + "-frames:v", + "1", + "-y", + "-loglevel", + "quiet", + output_path, + }) + else + out = { "mpv", input_path } + if take_thumbnail_at ~= "0" and is_video(input_path) then + if not accurate then + add({ "--hr-seek=no" }) + end + add({ "--start=" .. take_thumbnail_at }) + end + add({ + "--no-config", + "--msg-level=all=no", + "--vf=lavfi=[" .. vf .. ",format=bgra]", + "--audio=no", + "--sub=no", + "--frames=1", + "--image-display-duration=0", + "--of=rawvideo", + "--ovc=rawvideo", + "--o=" .. output_path, + }) + end + return out +end + +function generate_thumbnail(thumbnail_job) + if file_exists(thumbnail_job.output_path) then + return true + end + + local dir, _ = utils.split_path(thumbnail_job.output_path) + local tmp_output_path = utils.join_path(dir, script_id) + + local command = thumbnail_command( + thumbnail_job.input_path, + thumbnail_job.width, + thumbnail_job.height, + thumbnail_job.take_thumbnail_at, + tmp_output_path, + thumbnail_job.accurate, + thumbnail_job.with_mpv + ) + + local res = utils.subprocess({ args = command, cancellable = false }) + --"atomically" generate the output to avoid loading half-generated thumbnails (results in crashes) + if res.status == 0 then + local info = utils.file_info(tmp_output_path) + if not info or not info.is_file or info.size == 0 then + return false + end + if os.rename(tmp_output_path, thumbnail_job.output_path) then + return true + end + end + return false +end + +function handle_events(wait) + e = mp.wait_event(wait) + while e.event ~= "none" do + if e.event == "shutdown" then + return false + elseif e.event == "client-message" then + if e.args[1] == "push-thumbnail-front" or e.args[1] == "push-thumbnail-back" then + local thumbnail_job = { + requester = e.args[2], + input_path = e.args[3], + width = tonumber(e.args[4]), + height = tonumber(e.args[5]), + take_thumbnail_at = e.args[6], + output_path = e.args[7], + accurate = (e.args[8] == "true"), + with_mpv = (e.args[9] == "true"), + } + if e.args[1] == "push-thumbnail-front" then + jobs_queue[#jobs_queue + 1] = thumbnail_job + else + table.insert(jobs_queue, 1, thumbnail_job) + end + end + end + e = mp.wait_event(0) + end + return true +end + +local registration_timeout = 2 -- seconds +local registration_period = 0.2 + +-- shitty custom event loop because I can't figure out a better way +-- works pretty well though +function mp_event_loop() + local start_time = mp.get_time() + local sleep_time = registration_period + local last_broadcast_time = -registration_period + local broadcast_func + broadcast_func = function() + local now = mp.get_time() + if now >= start_time + registration_timeout then + mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name()) + sleep_time = 1e20 + broadcast_func = function() end + elseif now >= last_broadcast_time + registration_period then + mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name()) + last_broadcast_time = now + end + end + + while true do + if not handle_events(sleep_time) then + return + end + broadcast_func() + while #jobs_queue > 0 do + local thumbnail_job = jobs_queue[#jobs_queue] + if not failed[thumbnail_job.output_path] then + if generate_thumbnail(thumbnail_job) then + mp.commandv( + "script-message-to", + thumbnail_job.requester, + "thumbnail-generated", + thumbnail_job.output_path + ) + else + failed[thumbnail_job.output_path] = true + end + end + jobs_queue[#jobs_queue] = nil + if not handle_events(0) then + return + end + broadcast_func() + end + end +end diff --git a/mac/.config/mpv/scripts/history.lua b/mac/.config/mpv/scripts/history.lua new file mode 100644 index 0000000..974f42b --- /dev/null +++ b/mac/.config/mpv/scripts/history.lua @@ -0,0 +1,138 @@ +-- public domain +-- http://git.smrk.net/mpv-scripts/file/history.lua.html +-- Corrections and productive feedback appreciated, publicly +-- (<public@smrk.net>, inbox.smrk.net) or in private. + +local mpu = require("mp.utils") +local sqlite3 = require("lsqlite3") + +local history_db_path = mpu.join_path(os.getenv("XDG_DATA_HOME"), "history/mpv.sqlite") + +local db = nil +local last_insert_id = nil + +local function opendb() + local d, errcode, errmsg = sqlite3.open(history_db_path) + if not d then + error(("Failed to open %s: %d (%s)"):format(history_db_path, errcode, errmsg)) + end + + db = d + + local sql = [=[ + CREATE TABLE IF NOT EXISTS loaded_items( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + path TEXT NOT NULL, + filename TEXT NOT NULL, + title TEXT NOT NULL, + time_pos REAL, + date DATE NOT NULL + ); + ]=] + + if db:exec(sql) ~= sqlite3.OK then + error(("sqlite: %d (%s)"):format(db:errcode(), db:errmsg())) + end +end + +local function dbexec(sql, func, udata) + if not db then + opendb() + end + if db:exec(sql, func, udata) ~= sqlite3.OK then + error(("sqlite: %d (%s)"):format(db:errcode(), db:errmsg())) + end +end + +local function abspath() + local path = mp.get_property("path") + if path:find("://") then + return path + else + return mpu.join_path(mp.get_property("working-directory"), path) + end +end + +mp.register_event("file-loaded", function() + dbexec( + ([=[ + INSERT INTO loaded_items (path, filename, title, date) + VALUES( + "%s", + "%s", + "%s", + unixepoch() + ); + SELECT LAST_INSERT_ROWID(); + ]=]):format(abspath(), mp.get_property("filename"), mp.get_property("media-title")), + function(_udata, _cols, values, _names) + last_insert_id = values[1] + return 0 + end, + nil + ) +end) + +mp.add_hook("on_unload", 50, function(_hook) + local timepos = mp.get_property("audio-pts") or mp.get_property("time-pos") + if timepos then + dbexec(([=[ + UPDATE loaded_items + SET time_pos = %g + WHERE id = %d; + ]=]):format(timepos, last_insert_id)) + end +end) + +mp.register_event("shutdown", function() + if db then + db:close() + end +end) + +mp.add_key_binding("ctrl+r", "history-resume", function() + dbexec( + ([=[ + SELECT time_pos FROM loaded_items + WHERE time_pos NOTNULL AND title = "%s" + ORDER BY date DESC + LIMIT 1; + ]=]):format(mp.get_property("media-title")), + function(_udata, _cols, values, _names) + if values[1] then + mp.commandv("seek", values[1], "absolute", "exact") + end + return 0 + end, + nil + ) +end) + +mp.add_key_binding("alt+r", "play-recent", function() + local items = {} + dbexec( + [=[ + SELECT DISTINCT path, title FROM loaded_items + ORDER BY date DESC; + ]=], + function(_udata, _cols, values, _names) + if values[1] then + items[#items + 1] = values[1] .. "\x1f" .. values[2] + end + return 0 + end, + nil + ) + if items[1] then + local dmenu = mp.command_native({ + name = "subprocess", + args = { "dmenu", "-l", "20" }, + capture_stdout = true, + playback_only = false, + stdin_data = table.concat(items, "\n"), + }) + if dmenu.status == 0 then + mp.commandv("loadfile", dmenu.stdout:sub(1, dmenu.stdout:find("\x1f") - 1)) + end + end +end) diff --git a/mac/.config/mpv/scripts/mdmenu.lua b/mac/.config/mpv/scripts/mdmenu.lua new file mode 100644 index 0000000..1a0513c --- /dev/null +++ b/mac/.config/mpv/scripts/mdmenu.lua @@ -0,0 +1,285 @@ +--[[ + This file is part of mdmenu. + + mdmenu is free software: you can redistribute it and/or modify it + under the terms of the GNU Affero General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + mdmenu is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + for more details. + + You should have received a copy of the GNU Affero General Public License + along with mdmenu. If not, see <https://www.gnu.org/licenses/>. +]] + +local msg = require("mp.msg") +local mpopt = require("mp.options") +local utils = require("mp.utils") + +local state = { + playlist = nil, + playlist_current = nil, + tracklist = nil, + chapters = nil, + chapters_raw = nil, + wid = nil, +} + +local opt = { + embed = true, + preselect = false, + cmd = { "dmenu", "-i", "-l", "16" }, + + debug = false, +} + +local zassert = function() end +local ob = function(b) + return b and "[" or " " +end +local cb = function(b) + return b and "]" or " " +end + +local function format_time(t) + local h = math.floor(t / (60 * 60)) + t = t - (h * 60 * 60) + local m = math.floor(t / 60) + local s = t - (m * 60) + return string.format("%.2d:%.2d:%.2d", h, m, s) +end + +local function humantime_to_sec(str) + zassert(string.len(str) >= 8) + local h = tonumber(string.sub(str, 1, 2)) + local m = tonumber(string.sub(str, 4, 5)) + local s = tonumber(string.sub(str, 7, 8)) + if h and m and s and string.sub(str, 3, 3) == ":" and string.sub(str, 6, 6) == ":" then + return (h * 60 * 60) + (m * 60) + s + end + return nil +end + +local function grab_xid(kind, isconfigured) + zassert(kind == "vo-configured") + state.wid = nil -- clear it to account for runtime vo change + if isconfigured then + local wid = mp.get_property("window-id") + local vo_null = (wid == nil) and (mp.get_property("current-vo") == "null") + if wid == nil and not vo_null then + local pid = mp.get_property("pid") + local r = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = { "xdo", "id", "-p", pid }, + }) + if r.status == 0 and string.len(r.stdout) > 0 then + wid = string.match(r.stdout, "0x%x+") + end + end + if wid then + state.wid = wid + elseif not vo_null then + msg.warn("couldn't get mpv's xwindow id. make sure `xdo` is installed.") + end + end + msg.debug("[grab_xid]: isconfigured = " .. tostring(isconfigured) .. " wid = " .. tostring(state.wid)) +end + +local function set_playlist(kind, plist) + zassert(kind == "playlist") + local s = "" + local f = "%" .. (string.len(#plist) + ob(true):len() + cb(true):len()) .. "s" + state.playlist_current = nil + for k, pl in ipairs(plist) do + state.playlist_current = pl.current and k or state.playlist_current + s = s .. string.format(f, ob(pl.current) .. k .. cb(pl.current)) .. " " + s = s .. (pl.title or select(2, utils.split_path(pl.filename))) .. "\n" + end + state.playlist = s +end + +local function set_tracklist(kind, tlist) + zassert(kind == "track-list") + local s = "" + for _, t in ipairs(tlist) do + s = s .. ob(t.selected) .. string.sub(t.type, 1, 1) + s = s .. t.id .. cb(t.selected) .. " " + + if t.title then + s = s .. t.title .. " " + end + if t.lang then + s = s .. t.lang .. " " + end + s = s .. "\n" + end + state.tracklist = s +end + +local function set_chapter_list(kind, c) + zassert(kind == "chapter-list") + if c and #c > 0 then + local s = "" + for _, ch in ipairs(c) do + s = s .. format_time(ch.time) .. " " + s = s .. ch.title .. "\n" + end + state.chapters = s + state.chapters_raw = c + else + state.chapters = nil + state.chapters_raw = nil + end +end + +local function table_append(a, b) + for _, v in ipairs(b) do + table.insert(a, v) + end +end + +local function call_dmenu(stdin, extra_arg) + local cmd = {} + table_append(cmd, opt.cmd) + if state.wid then + table.insert(cmd, "-w") + table.insert(cmd, state.wid) + end + if extra_arg then + table_append(cmd, extra_arg) + end + msg.debug("[call_dmenu]: " .. table.concat(cmd, " ")) + return mp.command_native({ + name = "subprocess", + playback_only = false, + stdin_data = stdin, + capture_stdout = true, + args = cmd, + }) +end + +local function menu_playlist() + if state.playlist == nil then + return + end + local narg = nil + if opt.preselect and state.playlist_current ~= nil then + narg = { "-n", tostring(state.playlist_current - 1) } + end + local r = call_dmenu(state.playlist, narg) + if r.status == 0 and string.len(r.stdout) > 2 then + s = string.match(r.stdout, "[%s%[]*(%d+)") + if tonumber(s) then + mp.set_property("playlist-pos-1", s) + else + msg.warn("bad playlist position: " .. r.stdout) + end + end +end + +local function menu_tracklist() + if state.tracklist == nil then + return + end + + local r = call_dmenu(state.tracklist) + if r.status == 0 and string.len(r.stdout) > 4 then + local active = string.sub(r.stdout, 1, 1) == "[" + local type = string.sub(r.stdout, 2, 2) + local cmd = { ["v"] = "vid", ["a"] = "audio", ["s"] = "sub" } + local num = tonumber(string.sub(r.stdout, 3):match("%d+")) + local arg = { [false] = num, [true] = "no" } + + if cmd[type] and num ~= nil then + mp.commandv("set", cmd[type], arg[active]) + else + msg.warn("messed up input: " .. r.stdout) + end + end +end + +local function menu_chapters() + if state.chapters == nil then + return + end + local narg = nil + if opt.preselect then + local t = mp.get_property_native("time-pos") or 0 + local n = 0 + for i, c in ipairs(state.chapters_raw) do + if t > c.time then + n = i - 1 + end + end + narg = { "-n", tostring(n) } + end + + local r = call_dmenu(state.chapters, narg) + if r.status == 0 and string.len(r.stdout) > 8 then + local t = humantime_to_sec(r.stdout) + if t then + mp.set_property("time-pos", t) + else + msg.warn("bad chapter position: " .. r.stdout) + end + end +end + +local function menu_bindings() + local s = "" + local bind = mp.get_property_native("input-bindings") + for k, v in pairs(bind) do + s = s .. string.format("%-16s ", v.key) .. v.cmd .. "\n" + end + local r = call_dmenu(s) + -- if (r.status == 0 and string.len(r.stdout) > 0) then + -- local _, cmd = string.match(r.stdout, "(%w+)%s+(.+)\n"); + -- if (cmd ~= nil and string.len(cmd) > 0) then + -- mp.command(cmd) + -- end + -- end +end + +local function init() + mpopt.read_options(opt, "mdmenu") + if type(opt.cmd) == "string" then -- what a pain + local s = opt.cmd + opt.cmd = {} + for arg in string.gmatch(s, "[^,]+") do + table.insert(opt.cmd, arg) + end + end + + if opt.debug then + msg.debug("[ASSERTIONS] enabled") + zassert = assert + else + zassert(false) + end + + -- grab mpv's xwindow id + if opt.embed then + -- HACK: mpv doesn't open the window instantly by default. + -- so wait for 'vo-configured' to be true before trying to + -- grab the xid. + mp.observe_property("vo-configured", "native", grab_xid) + end + + mp.observe_property("playlist", "native", set_playlist) + mp.add_key_binding(nil, "playlist", menu_playlist) + + mp.observe_property("track-list", "native", set_tracklist) + mp.add_key_binding(nil, "tracklist", menu_tracklist) + + mp.observe_property("chapter-list", "native", set_chapter_list) + mp.add_key_binding(nil, "chapters", menu_chapters) + + mp.add_key_binding(nil, "bindings", menu_bindings) +end + +init() diff --git a/mac/.config/mpv/scripts/misc.lua b/mac/.config/mpv/scripts/misc.lua new file mode 100644 index 0000000..823b6fa --- /dev/null +++ b/mac/.config/mpv/scripts/misc.lua @@ -0,0 +1,594 @@ +--[[ + + https://github.com/stax76/mpv-scripts + + This script consist of various small unrelated features. + + Not used code sections can be removed. + + Bindings must be added manually to input.conf. + + + + Show media info on screen + ------------------------- + Prints detailed media info on the screen. + + Depends on the CLI tool 'mediainfo': + https://mediaarea.net/en/MediaInfo/Download + + In input.conf add: + i script-message-to misc print-media-info + + + + Load files/URLs from clipboard + ------------------------------ + Loads one or multiple files/URLs from the clipboard. + The clipboard format can be of type string or file object. + Allows appending to the playlist. + On Linux requires xclip being installed. + + In input.conf add: + ctrl+v script-message-to misc load-from-clipboard + ctrl+V script-message-to misc append-from-clipboard + + + + Cycle audio and subtitle tracks + ------------------------------- + If there are 20+ subtitle tracks, it's annoying cycling through all + of them. This feature allows you to cycle only through languages + you actually know. + + In mpv.conf define your known languages: + alang = de,deu,ger,en,eng #German/English + slang = en,eng,de,deu,ger #English/German + + If you don't know the language IDs, use the terminal, + mpv prints the language IDs there whenever a video file is loaded. + + In input.conf add: + SHARP script-message-to misc cycle-known-tracks audio + j script-message-to misc cycle-known-tracks sub up + J script-message-to misc cycle-known-tracks sub down + + ~~/script-opts/misc.conf: + #include_no_audio=no + #include_no_sub=yes + + ## If more than 5 tracks exist, only known are cycled, + ## define 0 to always cycle only known tracks. + #max_audio_track_count=5 + #max_sub_track_count=5 + + If you prefer a menu: + https://github.com/stax76/mpv-scripts?tab=readme-ov-file#command_palette + https://github.com/stax76/mpv-scripts#search-menu + https://github.com/dyphire/mpv-scripts/blob/main/track-list.lua + https://codeberg.org/NRK/mpv-toolbox/src/branch/master/mdmenu + https://github.com/tomasklaen/uosc + + The code was originally written by stax76, it was later + greatly improved by kaoneko making it much shorter. + + + Jump to a random position in the playlist + ----------------------------------------- + In input.conf add: + ctrl+r script-message-to misc playlist-random + + If pos=last it jumps to first instead of random. + + + + Quick Bookmark + -------------- + Creates or restores a bookmark. Supports one bookmark per video. + + Usage: + Create a folder in the following location: + ~~/script-settings/quick-bookmark/ + Or create it somewhere else, config at: + ~~/script-opts/misc.conf: + quick_bookmark_folder=<folder path> + + In input.conf add: + ctrl+q script-message-to misc quick-bookmark + + + + Playlist Next/Prev + ------------------ + Like the regular playlist-next/playlist-prev, but does not restart playback + of the first or last file, in case the first or last track already plays, + instead shows a OSD message. + + F11 script-message-to misc playlist-prev # Go to previous file in playlist + F12 script-message-to misc playlist-next # Go to next file in playlist + + + + Playlist First/Last + ------------------- + Navigates to the first or last track in the playlist, + in case the first or last track already plays, it does not + restart playback, instead shows a OSD message. + + Home script-message-to misc playlist-first # Go to first file in playlist + End script-message-to misc playlist-last # Go to last file in playlist + + + + Restart mpv + ----------- + Restarts mpv restoring the properties path, time-pos, + pause and volume, the playlist is not restored. + + r script-message-to misc restart-mpv + + + + Execute Lua code + ---------------- + Allows to execute Lua Code directly from input.conf. + + It's necessary to add a binding to input.conf: + #Navigates to the last file in the playlist + END script-message-to misc execute-lua-code "mp.set_property_number('playlist-pos', mp.get_property_number('playlist-count') - 1)" + + + + When seeking displays position and duration like so: + ---------------------------------------------------- + 70:00 / 80:00 + + Which is different from most players which use: + + 01:10:00 / 01:20:00 + + input.conf: + Right no-osd seek 5; script-message-to misc show-position + +]] +-- + +----- options + +local o = { + -- Cycle audio and subtitle tracks + include_no_audio = false, + include_no_sub = true, + max_audio_track_count = 5, + max_sub_track_count = 5, + -- Quick Bookmark + quick_bookmark_folder = "~~/script-settings/quick-bookmark/", +} + +local opt = require("mp.options") +opt.read_options(o) + +----- string + +function is_empty(input) + if input == nil or input == "" then + return true + end +end + +function contains(input, find) + if not is_empty(input) and not is_empty(find) then + return input:find(find, 1, true) + end +end + +function trim(input) + if not is_empty(input) then + return input:match("^%s*(.-)%s*$") + end +end + +function split(input, sep) + local tbl = {} + + if not is_empty(input) then + for str in string.gmatch(input, "([^" .. sep .. "]+)") do + table.insert(tbl, str) + end + end + + return tbl +end + +----- list + +function list_contains(list, value) + for _, v in pairs(list) do + if v == value then + return true + end + end + + return false +end + +----- math + +function round(value) + return value >= 0 and math.floor(value + 0.5) or math.ceil(value - 0.5) +end + +----- file + +function file_exists(path) + if is_empty(path) then + return false + end + local file = io.open(path, "r") + + if file ~= nil then + io.close(file) + return true + end +end + +function file_read(file_path) + local file = assert(io.open(file_path, "r")) + local content = file:read("*all") + file:close() + return content +end + +function file_write(path, content) + local file = assert(io.open(path, "w")) + file:write(content) + file:close() +end + +----- shared + +local is_windows = package.config:sub(1, 1) == "\\" +local msg = require("mp.msg") +local utils = require("mp.utils") + +function get_temp_dir() + if is_windows then + return os.getenv("TEMP") .. "\\" + else + return "/tmp/" + end +end + +----- Jump to a random position in the playlist + +mp.register_script_message("playlist-random", function() + local count = mp.get_property_number("playlist-count") + math.randomseed(os.time()) + local new_pos = math.random(0, count - 1) + local current_pos = mp.get_property_number("playlist-pos") + + if current_pos == count - 1 then + new_pos = 0 + end + + mp.set_property_number("playlist-pos", new_pos) +end) + +----- Execute Lua code + +mp.register_script_message("execute-lua-code", function(code) + loadstring(code)() +end) + +----- Alternative seek OSD message + +function pad_zero(value) + local value = round(value) + + if value > 9 then + return "" .. value + else + return "0" .. value + end +end + +function format_pos(value) + local seconds = round(value) + + if seconds < 0 then + seconds = 0 + end + + local pos_min_floor = math.floor(seconds / 60) + local sec_rest = seconds - pos_min_floor * 60 + + return pad_zero(pos_min_floor) .. ":" .. pad_zero(sec_rest) +end + +function show_pos() + local position = mp.get_property_number("time-pos") + local duration = mp.get_property_number("duration") + + if position > duration then + position = duration + end + + if position ~= 0 then + local percent = math.floor(position / duration * 100 + 0.5) + mp.osd_message(format_pos(position) .. " / " .. format_pos(duration) .. " (" .. percent .. "%)") + end +end + +mp.register_script_message("show-position", function(mode) + mp.add_timeout(0.05, show_pos) +end) + +----- Print media info on screen + +local media_info_cache = {} + +function show_text(text, duration, font_size) + mp.command('show-text "${osd-ass-cc/0}{\\\\fs' .. font_size .. "}${osd-ass-cc/1}" .. text .. '" ' .. duration) +end + +function get_media_info() + local path = mp.get_property("path") + + if media_info_cache[path] then + return media_info_cache[path] + end + + local media_info_format = + [[General;N: %FileNameExtension%\\nG: %Format%, %FileSize/String%, %Duration/String%, %OverallBitRate/String%, %Recorded_Date%\\n +Video;V: %Format%, %Format_Profile%, %Width%x%Height%, %BitRate/String%, %FrameRate% FPS\\n +Audio;A: %Language/String%, %Format%, %Format_Profile%, %BitRate/String%, %Channel(s)% ch, %SamplingRate/String%, %Title%\\n +Text;S: %Language/String%, %Format%, %Format_Profile%, %Title%\\n]] + + local format_file = get_temp_dir() .. "media-info-format-2.txt" + + if not file_exists(format_file) then + file_write(format_file, media_info_format) + end + + if contains(path, "://") or not file_exists(path) then + return + end + + local proc_result = mp.command_native({ + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = { "mediainfo", "--inform=file://" .. format_file, path }, + }) + + if proc_result.status == 0 then + local output = proc_result.stdout + + output = string.gsub(output, ", , ,", ",") + output = string.gsub(output, ", ,", ",") + output = string.gsub(output, ": , ", ": ") + output = string.gsub(output, ", \\n\r*\n", "\\n") + output = string.gsub(output, "\\n\r*\n", "\\n") + output = string.gsub(output, ", \\n", "\\n") + output = string.gsub(output, "%.000 FPS", " FPS") + output = string.gsub(output, "MPEG Audio, Layer 3", "MP3") + + media_info_cache[path] = output + + return output + end +end + +mp.register_script_message("print-media-info", function() + show_text(get_media_info(), 5000, 16) +end) + +----- Playlist Next/Prev + +mp.register_script_message("playlist-next", function() + local count = mp.get_property_number("playlist-count") + if count == 0 then + return + end + local pos = mp.get_property_number("playlist-pos") + + if pos == count - 1 then + mp.osd_message("Already last track") + return + end + + mp.set_property_number("playlist-pos", pos + 1) +end) + +mp.register_script_message("playlist-prev", function() + local count = mp.get_property_number("playlist-count") + if count == 0 then + return + end + local pos = mp.get_property_number("playlist-pos") + + if pos == 0 then + mp.osd_message("Already first track") + return + end + + mp.set_property_number("playlist-pos", pos - 1) +end) + +----- Playlist First/Last + +mp.register_script_message("playlist-first", function() + local count = mp.get_property_number("playlist-count") + if count == 0 then + return + end + local pos = mp.get_property_number("playlist-pos") + + if pos == 0 then + mp.osd_message("Already first track") + return + end + + mp.set_property_number("playlist-pos", 0) +end) + +mp.register_script_message("playlist-last", function() + local count = mp.get_property_number("playlist-count") + if count == 0 then + return + end + local pos = mp.get_property_number("playlist-pos") + + if pos == count - 1 then + mp.osd_message("Already last track") + return + end + + mp.set_property_number("playlist-pos", count - 1) +end) + +----- Load files from clipboard + +function loadfiles(mode) + if is_windows then + local ps_code = [[ + Add-Type -AssemblyName System.Windows.Forms + $containsFiles = [Windows.Forms.Clipboard]::ContainsFileDropList() + + if ($containsFiles) { + [Windows.Forms.Clipboard]::GetFileDropList() -join [Environment]::NewLine + } else { + Get-Clipboard + } + ]] + + proc_args = { "powershell", "-command", ps_code } + else + proc_args = { "xclip", "-o", "-selection", "clipboard" } + end + + subprocess = { + name = "subprocess", + args = proc_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + } + + proc_result = mp.command_native(subprocess) + + if proc_result.status < 0 then + msg.error("Error string: " .. proc_result.error_string) + msg.error("Error stderr: " .. proc_result.stderr) + return + end + + proc_output = trim(proc_result.stdout) + + if is_empty(proc_output) then + return + end + + if contains(proc_output, "\n") then + mp.commandv("loadlist", "memory://" .. proc_output, mode) + else + mp.commandv("loadfile", proc_output, mode) + end +end + +mp.register_script_message("load-from-clipboard", function() + loadfiles("replace") +end) + +mp.register_script_message("append-from-clipboard", function() + loadfiles("append") +end) + +----- Restart mpv + +mp.register_script_message("restart-mpv", function() + local restart_args = { + "mpv", + "--pause=" .. mp.get_property("pause"), + "--volume=" .. mp.get_property("volume"), + } + + local playlist_pos = mp.get_property_number("playlist-pos") + + if playlist_pos > -1 then + table.insert(restart_args, "--start=" .. mp.get_property("time-pos")) + table.insert(restart_args, mp.get_property("path")) + end + + mp.command_native({ + name = "subprocess", + playback_only = false, + detach = true, + args = restart_args, + }) + + mp.command("quit") +end) + +----- Cycle audio and subtitle tracks + +mp.register_script_message("cycle-known-tracks", function(mode, dir) + local m = mode:sub(1, 1) + local lang_list = {} + for _, lang in pairs(mp.get_property_native(m .. "lang")) do + lang_list[lang:gsub(" ", "")] = true + end + local track_list = mp.get_property_native("track-list") + local id_list = { o["include_no_" .. mode] and "no" or nil } + local count = 0 + local max_count = o["max_" .. mode .. "_track_count"] + + for _, track in pairs(track_list) do + if track.type == mode then + count = count + 1 + if lang_list[track.lang] or not track.lang or track.selected or not next(lang_list) then + table.insert(id_list, track.id) + end + end + end + + if #id_list < 2 then + return + elseif count <= max_count then + mp.command("cycle " .. mode .. " " .. (dir or "")) + else + mp.command("cycle-values " .. (dir == "down" and "!reverse " or "") .. m .. "id " .. table.concat(id_list, " ")) + end +end) + +----- Quick Bookmark + +mp.register_script_message("quick-bookmark", function() + local path = mp.get_property("path") + + if is_empty(path) then + return + end + + local folder = mp.command_native({ "expand-path", o.quick_bookmark_folder }) + + if utils.file_info(folder) == nil then + msg.error("Bookmark folder not found, create it at:\n" .. folder) + return + end + + if file_exists(path) then + _, path = utils.split_path(path) + path = utils.join_path(folder, path) + else + path = utils.join_path(folder, string.gsub(path, "[/\\:]", "")) + end + + if file_exists(path) then + mp.set_property_number("time-pos", tonumber(file_read(path))) + os.remove(path) + else + file_write(path, mp.get_property("time-pos")) + mp.osd_message("Bookmark saved") + end +end) diff --git a/mac/.config/mpv/scripts/modules.lua b/mac/.config/mpv/scripts/modules.lua new file mode 100644 index 0000000..3fceb6e --- /dev/null +++ b/mac/.config/mpv/scripts/modules.lua @@ -0,0 +1,5 @@ +local mpv_config_dir_path = require("mp").command_native({ "expand-path", "~~/" }) +function load(relative_path) + dofile(mpv_config_dir_path .. "/script-modules/" .. relative_path) +end +load("mpvSockets.lua") diff --git a/mac/.config/mpv/scripts/mpv_crop_script.lua b/mac/.config/mpv/scripts/mpv_crop_script.lua new file mode 100644 index 0000000..7b3f7be --- /dev/null +++ b/mac/.config/mpv/scripts/mpv_crop_script.lua @@ -0,0 +1,3438 @@ +--[[ + Copyright (C) 2017 AMM + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +]] +-- +--[[ + mpv_crop_script.lua 0.5.0 - commit 472281e (branch master) + Built on 2018-09-30 14:22:46 +]] +-- +--[[ + Assorted helper functions, from checking falsey values to path utils + to escaping and wrapping strings. + + Does not depend on other libs. +]] +-- + +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") +local utils = require("mp.utils") + +-- Determine platform -- +ON_WINDOWS = (package.config:sub(1, 1) ~= "/") + +-- Some helper functions needed to parse the options -- +function isempty(v) + return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) +end + +function divmod(a, b) + return math.floor(a / b), a % b +end + +-- Better modulo +function bmod(i, N) + return (i % N + N) % N +end + +-- Path utils +local path_utils = { + abspath = true, + split = true, + dirname = true, + basename = true, + + isabs = true, + normcase = true, + splitdrive = true, + join = true, + normpath = true, + relpath = true, +} + +-- Helpers +path_utils._split_parts = function(path, sep) + local path_parts = {} + for c in path:gmatch("[^" .. sep .. "]+") do + table.insert(path_parts, c) + end + return path_parts +end + +-- Common functions +path_utils.abspath = function(path) + if not path_utils.isabs(path) then + local cwd = os.getenv("PWD") or utils.getcwd() + path = path_utils.join(cwd, path) + end + return path_utils.normpath(path) +end + +path_utils.split = function(path) + local drive, path = path_utils.splitdrive(path) + -- Technically unix path could contain a \, but meh + local first_index, last_index = path:find("^.*[/\\]") + + if last_index == nil then + return drive .. "", path + else + local head = path:sub(0, last_index - 1) + local tail = path:sub(last_index + 1) + if head == "" then + head = sep + end + return drive .. head, tail + end +end + +path_utils.dirname = function(path) + local head, tail = path_utils.split(path) + return head +end + +path_utils.basename = function(path) + local head, tail = path_utils.split(path) + return tail +end + +path_utils.expanduser = function(path) + -- Expands the following from the start of the path: + -- ~ to HOME + -- ~~ to mpv config directory (first result of mp.find_config_file('.')) + -- ~~desktop to Windows desktop, otherwise HOME + -- ~~temp to Windows temp or /tmp/ + + local first_index, last_index = path:find("^.-[/\\]") + local head = path + local tail = "" + + local sep = "" + + if last_index then + head = path:sub(0, last_index - 1) + tail = path:sub(last_index + 1) + sep = path:sub(last_index, last_index) + end + + if head == "~~desktop" then + head = ON_WINDOWS and path_utils.join(os.getenv("USERPROFILE"), "Desktop") or os.getenv("HOME") + elseif head == "~~temp" then + head = ON_WINDOWS and os.getenv("TEMP") or (os.getenv("TMP") or "/tmp/") + elseif head == "~~" then + local mpv_config_dir = mp.find_config_file(".") + if mpv_config_dir then + head = path_utils.dirname(mpv_config_dir) + else + msg.warn("Could not find mpv config directory (using mp.find_config_file), using temp instead") + head = ON_WINDOWS and os.getenv("TEMP") or (os.getenv("TMP") or "/tmp/") + end + elseif head == "~" then + head = ON_WINDOWS and os.getenv("USERPROFILE") or os.getenv("HOME") + end + + return path_utils.normpath(path_utils.join(head .. sep, tail)) +end + +if ON_WINDOWS then + local sep = "\\" + local altsep = "/" + local curdir = "." + local pardir = ".." + local colon = ":" + + local either_sep = function(c) + return c == sep or c == altsep + end + + path_utils.isabs = function(path) + local prefix, path = path_utils.splitdrive(path) + return either_sep(path:sub(1, 1)) + end + + path_utils.normcase = function(path) + return path:gsub(altsep, sep):lower() + end + + path_utils.splitdrive = function(path) + if #path >= 2 then + local norm = path:gsub(altsep, sep) + if (norm:sub(1, 2) == (sep .. sep)) and (norm:sub(3, 3) ~= sep) then + -- UNC path + local index = norm:find(sep, 3) + if not index then + return "", path + end + + local index2 = norm:find(sep, index + 1) + if index2 == index + 1 then + return "", path + elseif not index2 then + index2 = path:len() + end + + return path:sub(1, index2 - 1), path:sub(index2) + elseif norm:sub(2, 2) == colon then + return path:sub(1, 2), path:sub(3) + end + end + return "", path + end + + path_utils.join = function(path, ...) + local paths = { ... } + + local result_drive, result_path = path_utils.splitdrive(path) + + function inner(p) + local p_drive, p_path = path_utils.splitdrive(p) + if either_sep(p_path:sub(1, 1)) then + -- Path is absolute + if p_drive ~= "" or result_drive == "" then + result_drive = p_drive + end + result_path = p_path + return + elseif p_drive ~= "" and p_drive ~= result_drive then + if p_drive:lower() ~= result_drive:lower() then + -- Different paths, ignore first + result_drive = p_drive + result_path = p_path + return + end + end + + if result_path ~= "" and not either_sep(result_path:sub(-1)) then + result_path = result_path .. sep + end + result_path = result_path .. p_path + end + + for i, p in ipairs(paths) do + inner(p) + end + + -- add separator between UNC and non-absolute path + if + result_path ~= "" + and not either_sep(result_path:sub(1, 1)) + and result_drive ~= "" + and result_drive:sub(-1) ~= colon + then + return result_drive .. sep .. result_path + end + return result_drive .. result_path + end + + path_utils.normpath = function(path) + if path:find("\\\\.\\", nil, true) == 1 or path:find("\\\\?\\", nil, true) == 1 then + -- Device names and literal paths - return as-is + return path + end + + path = path:gsub(altsep, sep) + local prefix, path = path_utils.splitdrive(path) + + if path:find(sep) == 1 then + prefix = prefix .. sep + path = path:gsub("^[\\]+", "") + end + + local comps = path_utils._split_parts(path, sep) + + local i = 1 + while i <= #comps do + if comps[i] == curdir then + table.remove(comps, i) + elseif comps[i] == pardir then + if i > 1 and comps[i - 1] ~= pardir then + table.remove(comps, i) + table.remove(comps, i - 1) + i = i - 1 + elseif i == 1 and prefix:match("\\$") then + table.remove(comps, i) + else + i = i + 1 + end + else + i = i + 1 + end + end + + if prefix == "" and #comps == 0 then + comps[1] = curdir + end + + return prefix .. table.concat(comps, sep) + end + + path_utils.relpath = function(path, start) + start = start or curdir + + local start_abs = path_utils.abspath(path_utils.normpath(start)) + local path_abs = path_utils.abspath(path_utils.normpath(path)) + + local start_drive, start_rest = path_utils.splitdrive(start_abs) + local path_drive, path_rest = path_utils.splitdrive(path_abs) + + if path_utils.normcase(start_drive) ~= path_utils.normcase(path_drive) then + -- Different drives + return nil + end + + local start_list = path_utils._split_parts(start_rest, sep) + local path_list = path_utils._split_parts(path_rest, sep) + + local i = 1 + for j = 1, math.min(#start_list, #path_list) do + if path_utils.normcase(start_list[j]) ~= path_utils.normcase(path_list[j]) then + break + end + i = j + 1 + end + + local rel_list = {} + for j = 1, (#start_list - i + 1) do + rel_list[j] = pardir + end + for j = i, #path_list do + table.insert(rel_list, path_list[j]) + end + + if #rel_list == 0 then + return curdir + end + + return path_utils.join(unpack(rel_list)) + end +else + -- LINUX + local sep = "/" + local curdir = "." + local pardir = ".." + + path_utils.isabs = function(path) + return path:sub(1, 1) == "/" + end + path_utils.normcase = function(path) + return path + end + path_utils.splitdrive = function(path) + return "", path + end + + path_utils.join = function(path, ...) + local paths = { ... } + + for i, p in ipairs(paths) do + if p:sub(1, 1) == sep then + path = p + elseif path == "" or path:sub(-1) == sep then + path = path .. p + else + path = path .. sep .. p + end + end + + return path + end + + path_utils.normpath = function(path) + if path == "" then + return curdir + end + + local initial_slashes = (path:sub(1, 1) == sep) and 1 + if initial_slashes and path:sub(2, 2) == sep and path:sub(3, 3) ~= sep then + initial_slashes = 2 + end + + local comps = path_utils._split_parts(path, sep) + local new_comps = {} + + for i, comp in ipairs(comps) do + if comp == "" or comp == curdir then + -- pass + elseif + comp ~= pardir + or (not initial_slashes and #new_comps == 0) + or (#new_comps > 0 and new_comps[#new_comps] == pardir) + then + table.insert(new_comps, comp) + elseif #new_comps > 0 then + table.remove(new_comps) + end + end + + comps = new_comps + path = table.concat(comps, sep) + if initial_slashes then + path = sep:rep(initial_slashes) .. path + end + + return (path ~= "") and path or curdir + end + + path_utils.relpath = function(path, start) + start = start or curdir + + local start_abs = path_utils.abspath(path_utils.normpath(start)) + local path_abs = path_utils.abspath(path_utils.normpath(path)) + + local start_list = path_utils._split_parts(start_abs, sep) + local path_list = path_utils._split_parts(path_abs, sep) + + local i = 1 + for j = 1, math.min(#start_list, #path_list) do + if start_list[j] ~= path_list[j] then + break + end + i = j + 1 + end + + local rel_list = {} + for j = 1, (#start_list - i + 1) do + rel_list[j] = pardir + end + for j = i, #path_list do + table.insert(rel_list, path_list[j]) + end + + if #rel_list == 0 then + return curdir + end + + return path_utils.join(unpack(rel_list)) + end +end +-- Path utils end + +-- Check if path is local (by looking if it's prefixed by a proto://) +local path_is_local = function(path) + local proto = path:match("(..-)://") + return proto == nil +end + +function Set(source) + local set = {} + for _, l in ipairs(source) do + set[l] = true + end + return set +end + +--------------------------- +-- More helper functions -- +--------------------------- + +function busy_wait(seconds) + local target = mp.get_time() + seconds + local cycles = 0 + while target > mp.get_time() do + cycles = cycles + 1 + end + return cycles +end + +-- Removes all keys from a table, without destroying the reference to it +function clear_table(target) + for key, value in pairs(target) do + target[key] = nil + end +end +function shallow_copy(target) + if type(target) == "table" then + local copy = {} + for k, v in pairs(target) do + copy[k] = v + end + return copy + else + return target + end +end + +function deep_copy(target) + local copy = {} + for k, v in pairs(target) do + if type(v) == "table" then + copy[k] = deep_copy(v) + else + copy[k] = v + end + end + return copy +end + +-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3 +function round_dec(num, idp) + local mult = 10 ^ (idp or 0) + return math.floor(num * mult + 0.5) / mult +end + +function file_exists(name) + local f = io.open(name, "rb") + if f ~= nil then + local ok, err, code = f:read(1) + io.close(f) + return code == nil + else + return false + end +end + +function path_exists(name) + local f = io.open(name, "rb") + if f ~= nil then + io.close(f) + return true + else + return false + end +end + +function create_directories(path) + local cmd + if ON_WINDOWS then + cmd = { args = { "cmd", "/c", "mkdir", path } } + else + cmd = { args = { "mkdir", "-p", path } } + end + utils.subprocess(cmd) +end + +function move_file(source_path, target_path) + local cmd + if ON_WINDOWS then + cmd = { cancellable = false, args = { "cmd", "/c", "move", "/Y", source_path, target_path } } + utils.subprocess(cmd) + else + -- cmd = { cancellable=false, args = {'mv', source_path, target_path } } + os.rename(source_path, target_path) + end +end + +function check_pid(pid) + -- Checks if a PID exists and returns true if so + local cmd, r + if ON_WINDOWS then + cmd = { cancellable = false, args = { + "tasklist", + "/FI", + ("PID eq %d"):format(pid), + } } + r = utils.subprocess(cmd) + return r.stdout:sub(1, 1) == "\13" + else + cmd = { cancellable = false, args = { + "sh", + "-c", + ("kill -0 %d 2>/dev/null"):format(pid), + } } + r = utils.subprocess(cmd) + return r.status == 0 + end +end + +function kill_pid(pid) + local cmd, r + if ON_WINDOWS then + cmd = { cancellable = false, args = { "taskkill", "/F", "/PID", tostring(pid) } } + else + cmd = { cancellable = false, args = { "kill", tostring(pid) } } + end + r = utils.subprocess(cmd) + return r.status == 0, r +end + +-- Find an executable in PATH or CWD with the given name +function find_executable(name) + local delim = ON_WINDOWS and ";" or ":" + + local pwd = os.getenv("PWD") or utils.getcwd() + local path = os.getenv("PATH") + + local env_path = pwd .. delim .. path -- Check CWD first + + local result, filename + for path_dir in env_path:gmatch("[^" .. delim .. "]+") do + filename = path_utils.join(path_dir, name) + if file_exists(filename) then + result = filename + break + end + end + + return result +end + +local ExecutableFinder = { path_cache = {} } +-- Searches for an executable and caches the result if any +function ExecutableFinder:get_executable_path(name, raw_name) + name = ON_WINDOWS and not raw_name and (name .. ".exe") or name + + if self.path_cache[name] == nil then + self.path_cache[name] = find_executable(name) or false + end + return self.path_cache[name] +end + +-- Format seconds to HH.MM.SS.sss +function format_time(seconds, sep, decimals) + decimals = decimals == nil and 3 or decimals + sep = sep and sep or ":" + local s = seconds + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + local second_format = string.format("%%0%d.%df", 2 + (decimals > 0 and decimals + 1 or 0), decimals) + + return string.format("%02d" .. sep .. "%02d" .. sep .. second_format, h, m, s) +end + +-- Format seconds to 1h 2m 3.4s +function format_time_hms(seconds, sep, decimals, force_full) + decimals = decimals == nil and 1 or decimals + sep = sep ~= nil and sep or " " + + local s = seconds + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + if force_full or h > 0 then + return string.format("%dh" .. sep .. "%dm" .. sep .. "%." .. tostring(decimals) .. "fs", h, m, s) + elseif m > 0 then + return string.format("%dm" .. sep .. "%." .. tostring(decimals) .. "fs", m, s) + else + return string.format("%." .. tostring(decimals) .. "fs", s) + end +end + +-- Writes text on OSD and console +function log_info(txt, timeout) + timeout = timeout or 1.5 + msg.info(txt) + mp.osd_message(txt, timeout) +end + +-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-" +function join_table(source, before, after, sep) + before = before or "" + after = after or "" + sep = sep or ", " + local result = "" + for i, v in pairs(source) do + if not isempty(v) then + local part = before .. v .. after + if i == 1 then + result = part + else + result = result .. sep .. part + end + end + end + return result +end + +function wrap(s, char) + char = char or "'" + return char .. s .. char +end +-- Wraps given string into 'string' and escapes any 's in it +function escape_and_wrap(s, char, replacement) + char = char or "'" + replacement = replacement or "\\" .. char + return wrap(string.gsub(s, char, replacement), char) +end +-- Escapes single quotes in a string and wraps the input in single quotes +function escape_single_bash(s) + return escape_and_wrap(s, "'", "'\\''") +end + +-- Returns (a .. b) if b is not empty or nil +function joined_or_nil(a, b) + return not isempty(b) and (a .. b) or nil +end + +-- Put items from one table into another +function extend_table(target, source) + for i, v in pairs(source) do + table.insert(target, v) + end +end + +-- Creates a handle and filename for a temporary random file (in current directory) +function create_temporary_file(base, mode, suffix) + local handle, filename + suffix = suffix or "" + while true do + filename = base .. tostring(math.random(1, 5000)) .. suffix + handle = io.open(filename, "r") + if not handle then + handle = io.open(filename, mode) + break + end + io.close(handle) + end + return handle, filename +end + +function get_processor_count() + local proc_count + + if ON_WINDOWS then + proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS")) + else + local cpuinfo_handle = io.open("/proc/cpuinfo") + if cpuinfo_handle ~= nil then + local cpuinfo_contents = cpuinfo_handle:read("*a") + local _, replace_count = cpuinfo_contents:gsub("processor", "") + proc_count = replace_count + end + end + + if proc_count and proc_count > 0 then + return proc_count + else + return nil + end +end + +function substitute_values(string, values) + local substitutor = function(match) + if match == "%" then + return "%" + else + -- nil is discarded by gsub + return values[match] + end + end + + local substituted = string:gsub("%%(.)", substitutor) + return substituted +end + +-- ASS HELPERS -- +function round_rect_top(ass, x0, y0, x1, y1, r) + local c = 0.551915024494 * r -- circle approximation + ass:move_to(x0 + r, y0) + ass:line_to(x1 - r, y0) -- top line + if r > 0 then + ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner + end + ass:line_to(x1, y1) -- right line + ass:line_to(x0, y1) -- bottom line + ass:line_to(x0, y0 + r) -- left line + if r > 0 then + ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner + end +end + +function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl) + local c = 0.551915024494 + ass:move_to(x0 + rtl, y0) + ass:line_to(x1 - rtr, y0) -- top line + if rtr > 0 then + ass:bezier_curve(x1 - rtr + rtr * c, y0, x1, y0 + rtr - rtr * c, x1, y0 + rtr) -- top right corner + end + ass:line_to(x1, y1 - rbr) -- right line + if rbr > 0 then + ass:bezier_curve(x1, y1 - rbr + rbr * c, x1 - rbr + rbr * c, y1, x1 - rbr, y1) -- bottom right corner + end + ass:line_to(x0 + rbl, y1) -- bottom line + if rbl > 0 then + ass:bezier_curve(x0 + rbl - rbl * c, y1, x0, y1 - rbl + rbl * c, x0, y1 - rbl) -- bottom left corner + end + ass:line_to(x0, y0 + rtl) -- left line + if rtl > 0 then + ass:bezier_curve(x0, y0 + rtl - rtl * c, x0 + rtl - rtl * c, y0, x0 + rtl, y0) -- top left corner + end +end +--[[ + A slightly more advanced option parser for scripts. + It supports documenting the options, and can export an example config. + It also can rewrite the config file with overrides, preserving the + original lines and appending changes to the end, along with profiles. + + Does not depend on other libs. +]] +-- + +local OptionParser = {} +OptionParser.__index = OptionParser + +setmetatable(OptionParser, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function OptionParser.new(identifier) + local self = setmetatable({}, OptionParser) + + self.identifier = identifier + self.config_file = self:_get_config_file(identifier) + + self.OVERRIDE_START = "# Script-saved overrides below this line. Edits will be lost!" + + -- All the options contained, as a list + self.options_list = {} + -- All the options contained, as a table with keys. See add_option + self.options = {} + + self.default_profile = { name = "default", values = {}, loaded = {}, config_lines = {} } + self.profiles = {} + + self.active_profile = self.default_profile + + -- Recusing metatable magic to wrap self.values.key.sub_key into + -- self.options["key.sub_key"].value, with support for assignments as well + function get_value_or_mapper(key) + local cur_option = self.options[key] + + if cur_option then + -- Wrap tables + if cur_option.type == "table" then + return setmetatable({}, { + __index = function(t, sub_key) + return get_value_or_mapper(key .. "." .. sub_key) + end, + __newindex = function(t, sub_key, value) + local sub_option = self.options[key .. "." .. sub_key] + if sub_option and sub_option.type ~= "table" then + self.active_profile.values[key .. "." .. sub_key] = value + end + end, + }) + else + return self.active_profile.values[key] + end + end + end + + -- Same recusing metatable magic to get the .default + function get_default_or_mapper(key) + local cur_option = self.options[key] + + if cur_option then + if cur_option.type == "table" then + return setmetatable({}, { + __index = function(t, sub_key) + return get_default_or_mapper(key .. "." .. sub_key) + end, + }) + else + return cur_option.default + -- return self.active_profile.values[key] + end + end + end + + -- Easy lookups for values and defaults + self.values = setmetatable({}, { + __index = function(t, key) + return get_value_or_mapper(key) + end, + __newindex = function(t, key, value) + local option = self.options[key] + if option then + -- option.value = value + self.active_profile.values[key] = value + end + end, + }) + + self.defaults = setmetatable({}, { + __index = function(t, key) + return get_default_or_mapper(key) + end, + }) + + -- Hacky way to run after the script is initialized and options (hopefully) added + mp.add_timeout(0, function() + -- Handle a '--script-opts identifier-example-config=example.conf' to save an example config to a file + local example_dump_filename = mp.get_opt(self.identifier .. "-example-config") + if example_dump_filename then + self:save_example_options(example_dump_filename) + end + local explain_config = mp.get_opt(self.identifier .. "-explain-config") + if explain_config then + self:explain_options() + end + + if (example_dump_filename or explain_config) and mp.get_property_native("options/idle") then + msg.info("Exiting.") + mp.commandv("quit") + end + end) + + return self +end + +function OptionParser:activate_profile(profile_name) + local chosen_profile = nil + if profile_name then + for i, profile in ipairs(self.profiles) do + if profile.name == profile_name then + chosen_profile = profile + break + end + end + else + chosen_profile = self.default_profile + end + + if chosen_profile then + self.active_profile = chosen_profile + end +end + +function OptionParser:add_option(key, default, description, pad_before) + if self.options[key] ~= nil then + -- Already exists! + return nil + end + + local option_index = #self.options_list + 1 + local option_type = type(default) + + -- Check if option is an array + if option_type == "table" then + if default._array then + option_type = "array" + end + default._array = nil + end + + local option = { + index = option_index, + type = option_type, + key = key, + default = default, + + description = description, + pad_before = pad_before, + } + + self.options_list[option_index] = option + + -- table-options are just containers for sub-options and have no value + if option_type == "table" then + option.default = nil + + -- Add sub-options + for i, sub_option_data in ipairs(default) do + local sub_key = sub_option_data[1] + sub_option_data[1] = key .. "." .. sub_key + local sub_option = self:add_option(unpack(sub_option_data)) + end + end + + if key then + self.options[key] = option + self.default_profile.values[option.key] = option.default + end + + return option +end + +function OptionParser:add_options(list_of_options) + for i, option_args in ipairs(list_of_options) do + self:add_option(unpack(option_args)) + end +end + +function OptionParser:restore_defaults() + for key, option in pairs(self.options) do + if option.type ~= "table" then + self.active_profile.values[option.key] = option.default + end + end +end + +function OptionParser:restore_loaded() + for key, option in pairs(self.options) do + if option.type ~= "table" then + -- Non-default profiles will have an .loaded entry for all options + local value = self.active_profile.loaded[option.key] + if value == nil then + value = option.default + end + self.active_profile.values[option.key] = value + end + end +end + +function OptionParser:_get_config_file(identifier) + local config_filename = "script-opts/" .. identifier .. ".conf" + local config_file = mp.find_config_file(config_filename) + + if not config_file then + config_filename = "script-opts/" .. identifier .. ".conf" + config_file = mp.find_config_file(config_filename) + + if config_file then + msg.warn("lua-settings/ is deprecated, use directory script-opts/") + end + end + + return config_file +end + +function OptionParser:value_to_string(value) + if type(value) == "boolean" then + if value then + value = "yes" + else + value = "no" + end + elseif type(value) == "table" then + return utils.format_json(value) + end + return tostring(value) +end + +function OptionParser:string_to_value(option_type, value) + if option_type == "boolean" then + if value == "yes" or value == "true" then + value = true + elseif value == "no" or value == "false" then + value = false + else + -- can't parse as boolean + value = nil + end + elseif option_type == "number" then + value = tonumber(value) + if value == nil then + -- Can't parse as number + end + elseif option_type == "array" then + value = utils.parse_json(value) + end + return value +end + +function OptionParser:get_profile(profile_name) + for i, profile in ipairs(self.profiles) do + if profile.name == profile_name then + return profile + end + end +end + +function OptionParser:create_profile(profile_name, base_on_original) + if not self:get_profile(profile_name) then + new_profile = { name = profile_name, values = {}, loaded = {}, config_lines = {} } + + if base_on_original then + -- Copy values from default config + for k, v in pairs(self.default_profile.values) do + new_profile.values[k] = v + end + for k, v in pairs(self.default_profile.loaded) do + new_profile.loaded[k] = v + end + else + -- Copy current values, but not loaded + for k, v in pairs(self.active_profile.values) do + new_profile.values[k] = v + end + end + + table.insert(self.profiles, new_profile) + return new_profile + end +end + +function OptionParser:load_options() + if not self.config_file then + return + end + local file = io.open(self.config_file, "r") + if not file then + return + end + + local trim = function(text) + return (text:gsub("^%s*(.-)%s*$", "%1")) + end + + local current_profile = self.default_profile + local override_reached = false + local line_index = 1 + + -- Read all lines in advance + local lines = {} + for line in file:lines() do + table.insert(lines, line) + end + file:close() + + local total_lines = #lines + + while line_index < total_lines + 1 do + local line = lines[line_index] + + local profile_name = line:match("^%[(..-)%]$") + + if line == self.OVERRIDE_START then + override_reached = true + elseif line:find("#") == 1 then + -- Skip comments + elseif profile_name then + current_profile = self:get_profile(profile_name) or self:create_profile(profile_name, true) + override_reached = false + else + local key, value = line:match("^(..-)=(.+)$") + if key then + key = trim(key) + value = trim(value) + + local option = self.options[key] + if not option then + msg.warn(("%s:%d ignoring unknown key '%s'"):format(self.config_file, line_index, key)) + elseif option.type == "table" then + msg.warn(("%s:%d ignoring value for table-option %s"):format(self.config_file, line_index, key)) + else + -- If option is an array, make sure we read all lines + if option.type == "array" then + local start_index = line_index + -- Read lines until one ends with ] + while not value:match("%]%s*$") do + line_index = line_index + 1 + if line_index > total_lines then + msg.error( + ("%s:%d non-ending %s for key '%s'"):format( + self.config_file, + start_index, + option.type, + key + ) + ) + end + value = value .. trim(lines[line_index]) + end + end + local parsed_value = self:string_to_value(option.type, value) + + if parsed_value == nil then + msg.error( + ("%s:%d error parsing value '%s' for key '%s' (as %s)"):format( + self.config_file, + line_index, + value, + key, + option.type + ) + ) + else + current_profile.values[option.key] = parsed_value + if not override_reached then + current_profile.loaded[option.key] = parsed_value + end + end + end + end + end + + if not override_reached and not profile_name then + table.insert(current_profile.config_lines, line) + end + + line_index = line_index + 1 + end +end + +function OptionParser:save_options() + if not self.config_file then + return nil, "no configuration file found" + end + + local file = io.open(self.config_file, "w") + if not file then + return nil, "unable to open configuration file for writing" + end + + local profiles = { self.default_profile } + for i, profile in ipairs(self.profiles) do + table.insert(profiles, profile) + end + + local out_lines = {} + + local add_linebreak = function() + if out_lines[#out_lines] ~= "" then + table.insert(out_lines, "") + end + end + + for profile_index, profile in ipairs(profiles) do + local profile_override_lines = {} + for option_index, option in ipairs(self.options_list) do + local option_value = profile.values[option.key] + local option_loaded = profile.loaded[option.key] + + if option_loaded == nil then + option_loaded = self.default_profile.loaded[option.key] + end + if option_loaded == nil then + option_loaded = option.default + end + + -- If value is different from default AND loaded value, store it in array + if option.key then + if option_value ~= option_loaded then + table.insert( + profile_override_lines, + ("%s=%s"):format(option.key, self:value_to_string(option_value)) + ) + end + end + end + + if (#profile.config_lines > 0 or #profile_override_lines > 0) and profile ~= self.default_profile then + -- Write profile name, if this is not default profile + add_linebreak() + table.insert(out_lines, ("[%s]"):format(profile.name)) + end + + -- Write original config lines + for line_index, line in ipairs(profile.config_lines) do + table.insert(out_lines, line) + end + -- end + + if #profile_override_lines > 0 then + -- Add another newline before the override comment, if needed + add_linebreak() + + table.insert(out_lines, self.OVERRIDE_START) + for override_line_index, override_line in ipairs(profile_override_lines) do + table.insert(out_lines, override_line) + end + end + end + + -- Add a final linebreak if needed + add_linebreak() + + file:write(table.concat(out_lines, "\n")) + file:close() + + return true +end + +function OptionParser:get_default_config_lines() + local example_config_lines = {} + + for option_index, option in ipairs(self.options_list) do + if option.pad_before then + table.insert(example_config_lines, "") + end + + if option.description then + for description_line in option.description:gmatch("[^\r\n]+") do + table.insert(example_config_lines, ("# " .. description_line)) + end + end + if option.key and option.type ~= "table" then + table.insert(example_config_lines, ("%s=%s"):format(option.key, self:value_to_string(option.default))) + end + end + return example_config_lines +end + +function OptionParser:explain_options() + local example_config_lines = self:get_default_config_lines() + msg.info(table.concat(example_config_lines, "\n")) +end + +function OptionParser:save_example_options(filename) + local file = io.open(filename, "w") + if not file then + msg.error("Unable to open file '" .. filename .. "' for writing") + else + local example_config_lines = self:get_default_config_lines() + file:write(table.concat(example_config_lines, "\n")) + file:close() + msg.info("Wrote example config to file '" .. filename .. "'") + end +end +local SCRIPT_NAME = "mpv_crop_script" + +local SCRIPT_KEYBIND = "c" +local SCRIPT_HANDLER = "crop-screenshot" + +-------------------- +-- Script options -- +-------------------- + +local script_options = OptionParser(SCRIPT_NAME) +local option_values = script_options.values + +script_options:add_options({ + { nil, nil, "mpv_crop_script.lua options and default values" }, + { nil, nil, "Output options #", true }, + { + "output_template", + "${filename}${!is_image: ${#pos:%02h.%02m.%06.3s}}${!full: ${crop_w}x${crop_h}} ${%unique:%03d}.${ext}", + "Filename output template. See README.md for property expansion documentation.", + }, + { + nil, + nil, + [[Script-provided properties: + filename - filename without extension + file_ext - original extension without leading dot + path - original file path + pos - playback time + ext - output file extension without leading dot + crop_w - crop width + crop_h - crop height + crop_x - left + crop_y - top + crop_x2 - right + crop_y2 - bottom + full - boolean denoting a full (temporary) screenshot instead of crop + is_image - boolean denoting the source file is likely an image (zero duration and position) + unique - counter that will increase per each existing filename, until a unique name is found]], + }, + + { "output_format", "png", "Format (encoder) to save final crops in. For example, png, mjpeg, targa, bmp" }, + { "output_extension", "", "Output extension. Leave blank to try to choose from the encoder (if supported)" }, + + { + "create_directories", + false, + "Whether to create the directories in the final output path (defined by output_template)", + }, + { + "skip_screenshot_for_images", + true, + "If the current file is an image, skip taking a temporary screenshot and crop the image directly", + }, + { "keep_original", false, "Keep the full-sized temporary screenshot as well" }, + + { nil, nil, "Crop tool options #", true }, + { + "overlay_transparency", + 160, + "Transparency (0 - opaque, 255 - transparent) of the dim overlay on the non-cropped area", + }, + { "overlay_lightness", 0, "Ligthness (0 - black, 255 - white) of the dim overlay on the non-cropped area" }, + { "draw_mouse", false, "Draw the crop crosshair" }, + { "guide_type", "none", "Crop guide type. One of: none, grid, center" }, + { "color_invert", false, "Use black lines instead of white for the crop frame and crosshair" }, + { + "auto_invert", + false, + "Try to check if video is light or dark upon opening crop tool, and invert the colors if necessary", + }, + + { nil, nil, "Misc options #", true }, + { + "warn_about_template", + true, + "Warn about output_template missing ${ext}, to ensure the extension is not missing", + }, + { "disable_keybind", false, "Disable the built-in keybind" }, +}) + +-- Read user-given options, if any +script_options:load_options() +--[[ + DisplayState keeps track of the current display state, and can + handle mapping between video-space coords and display-space coords. + Handles panscan and offsets and aligns and all that, following what + mpv itself does (video/out/aspect.c). + + Does not depend on other libs. +]] +-- + +local DisplayState = {} +DisplayState.__index = DisplayState + +setmetatable(DisplayState, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function DisplayState.new() + local self = setmetatable({}, DisplayState) + + self:reset() + + return self +end + +function DisplayState:reset() + self.screen = {} -- Display (window, fullscreen) size + self.video = {} -- Video size + self.scale = {} -- video / screen + self.bounds = {} -- Video rect within display + + self.screen_ready = false + self.video_ready = false + + -- Stores internal display state (panscan, align, zoom etc) + self.current_state = nil +end + +function DisplayState:setup_events() + mp.register_event("file-loaded", function() + self:event_file_loaded() + end) +end + +function DisplayState:event_file_loaded() + self:reset() + self:recalculate_bounds(true) +end + +-- Turns screen-space XY to video XY (can go negative) +function DisplayState:screen_to_video(x, y) + local nx = (x - self.bounds.left) * self.scale.x + local ny = (y - self.bounds.top) * self.scale.y + return nx, ny +end + +-- Turns video-space XY to screen XY +function DisplayState:video_to_screen(x, y) + local nx = (x / self.scale.x) + self.bounds.left + local ny = (y / self.scale.y) + self.bounds.top + return nx, ny +end + +function DisplayState:_collect_display_state() + local screen_w, screen_h, screen_aspect = mp.get_osd_size() + + local state = { + screen_w = screen_w, + screen_h = screen_h, + screen_aspect = screen_aspect, + + video_w = mp.get_property_native("dwidth"), + video_h = mp.get_property_native("dheight"), + + video_w_raw = mp.get_property_native("video-out-params/w"), + video_h_raw = mp.get_property_native("video-out-params/h"), + + panscan = mp.get_property_native("panscan"), + video_zoom = mp.get_property_native("video-zoom"), + video_unscaled = mp.get_property_native("video-unscaled"), + + video_align_x = mp.get_property_native("video-align-x"), + video_align_y = mp.get_property_native("video-align-y"), + + video_pan_x = mp.get_property_native("video-pan-x"), + video_pan_y = mp.get_property_native("video-pan-y"), + + fullscreen = mp.get_property_native("fullscreen"), + keepaspect = mp.get_property_native("keepaspect"), + keepaspect_window = mp.get_property_native("keepaspect-window"), + } + + return state +end + +function DisplayState:_state_changed(state) + if self.current_state == nil then + return true + end + + for k in pairs(state) do + if state[k] ~= self.current_state[k] then + return true + end + end + return false +end + +function DisplayState:recalculate_bounds(forced) + local new_state = self:_collect_display_state() + if not (forced or self:_state_changed(new_state)) then + -- Early out + return self.screen_ready + end + self.current_state = new_state + + -- Store screen dimensions + self.screen.width = new_state.screen_w + self.screen.height = new_state.screen_h + self.screen.ratio = new_state.screen_w / new_state.screen_h + self.screen_ready = true + + -- Video dimensions + if new_state.video_w and new_state.video_h then + self.video.width = new_state.video_w + self.video.height = new_state.video_h + self.video.ratio = new_state.video_w / new_state.video_h + + -- This magic has been adapted from mpv's own video/out/aspect.c + + if new_state.keepaspect then + local scaled_w, scaled_h = self:_aspect_calc_panscan(new_state) + local video_left, video_right = self:_split_scaling( + new_state.screen_w, + scaled_w, + new_state.video_zoom, + new_state.video_align_x, + new_state.video_pan_x + ) + local video_top, video_bottom = self:_split_scaling( + new_state.screen_h, + scaled_h, + new_state.video_zoom, + new_state.video_align_y, + new_state.video_pan_y + ) + self.bounds = { + left = video_left, + right = video_right, + + top = video_top, + bottom = video_bottom, + + width = video_right - video_left, + height = video_bottom - video_top, + } + else + self.bounds = { + left = 0, + top = 0, + right = self.screen.width, + bottom = self.screen.height, + + width = self.screen.width, + height = self.screen.height, + } + end + + self.scale.x = new_state.video_w_raw / self.bounds.width + self.scale.y = new_state.video_h_raw / self.bounds.height + + self.video_ready = true + end + + return self.screen_ready +end + +function DisplayState:_aspect_calc_panscan(state) + -- From video/out/aspect.c + local f_width = state.screen_w + local f_height = (state.screen_w / state.video_w) * state.video_h + + if f_height > state.screen_h or f_height < state.video_h_raw then + local tmp_w = (state.screen_h / state.video_h) * state.video_w + if tmp_w <= state.screen_w then + f_height = state.screen_h + f_width = tmp_w + end + end + + local vo_panscan_area = state.screen_h - f_height + + local f_w = f_width / f_height + local f_h = 1 + if vo_panscan_area == 0 then + vo_panscan_area = state.screen_w - f_width + f_w = 1 + f_h = f_height / f_width + end + + if state.video_unscaled then + vo_panscan_area = 0 + if + state.video_unscaled ~= "downscale-big" + or ((state.video_w <= state.screen_w) and (state.video_h <= state.screen_h)) + then + f_width = state.video_w + f_height = state.video_h + end + end + + local scaled_w = math.floor(f_width + vo_panscan_area * state.panscan * f_w) + local scaled_h = math.floor(f_height + vo_panscan_area * state.panscan * f_h) + return scaled_w, scaled_h +end + +function DisplayState:_split_scaling(dst_size, scaled_src_size, zoom, align, pan) + -- From video/out/aspect.c as well + scaled_src_size = math.floor(scaled_src_size * 2 ^ zoom) + align = (align + 1) / 2 + + local dst_start = (dst_size - scaled_src_size) * align + pan * scaled_src_size + local dst_end = dst_start + scaled_src_size + + -- We don't actually want these - we want to go out of bounds! + -- dst_start = math.max(0, dst_start) + -- dst_end = math.min(dst_size, dst_end) + + return math.floor(dst_start), math.floor(dst_end) +end +--[[ + ASSCropper is a tool to get crop values with a visual tool + that handles mouse clicks and drags to manipulate a crop box, + with a crosshair, guides, etc. + + Indirectly depends on DisplayState (as a given instance). +]] +-- + +local ASSCropper = {} +ASSCropper.__index = ASSCropper + +setmetatable(ASSCropper, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function ASSCropper.new(display_state) + local self = setmetatable({}, ASSCropper) + local script_name = mp.get_script_name() + self.keybind_group = script_name .. "_asscropper_binds" + self.cropdetect_label = script_name .. "_asscropper_cropdetect" + self.blackframe_label = script_name .. "_asscropper_blackframe" + self.crop_label = script_name .. "_asscropper_crop" + + self.display_state = display_state + + self.tick_callback = nil + self.tick_timer = mp.add_periodic_timer(1 / 60, function() + if self.tick_callback then + self.tick_callback() + end + end) + self.tick_timer:stop() + + self.text_size = 18 + + self.overlay_transparency = 160 + self.overlay_lightness = 0 + + self.corner_size = 40 + self.corner_required_size = self.corner_size * 3 + + self.guide_type_names = { + [0] = "No guides", + [1] = "Grid guides", + [2] = "Center guides", + } + self.guide_type_count = 3 + + self.default_options = { + even_dimensions = false, + guide_type = 0, + draw_mouse = false, + draw_help = true, + color_invert = false, + auto_invert = false, + } + self.options = default_options + + self.active = false + + self.mouse_screen = { x = 0, y = 0 } + self.mouse_video = { x = 0, y = 0 } + + -- Crop in video-space + self.current_crop = nil + + self.dragging = 0 + self.drag_start = { x = 0, y = 0 } + self.restrict_ratio = false + + self.testing_crop = false + + self.detecting_crop = nil + self.cropdetect_wait = nil + self.cropdetect_timeout = nil + + self.detecting_blackframe = nil + self.blackframe_wait = nil + self.blackframe_timeout = nil + + self.nudges = { + NUDGE_LEFT = { -1, 0, -1, 0 }, + NUDGE_UP = { 0, -1, 0, -1 }, + NUDGE_RIGHT = { 1, 0, 1, 0 }, + NUDGE_DOWN = { 0, 1, 0, 1 }, + } + + self.resizes = { + SHRINK_LEFT = { 1, 0, 0, 0 }, + SHRINK_TOP = { 0, 1, 0, 0 }, + SHRINK_RIGHT = { 0, 0, -1, 0 }, + SHRINK_BOT = { 0, 0, 0, -1 }, + + GROW_LEFT = { -1, 0, 0, 0 }, + GROW_TOP = { 0, -1, 0, 0 }, + GROW_RIGHT = { 0, 0, 1, 0 }, + GROW_BOT = { 0, 0, 0, 1 }, + } + + self._key_binds = { + { + "mouse_move", + function() + self:update_mouse_position() + end, + }, + { + "mouse_btn0", + function(e) + self:on_mouse("mouse_btn0", e) + end, + { complex = true }, + }, + { + "shift+mouse_btn0", + function(e) + self:on_mouse("mouse_btn0", e, true) + end, + { complex = true }, + }, + + { + "ctrl+shift+c", + function() + self:key_event("CROSSHAIR") + end, + }, + { + "ctrl+shift+d", + function() + self:key_event("CROP_DETECT") + end, + }, + { + "ctrl+shift+x", + function() + self:key_event("GUIDES") + end, + }, + { + "ctrl+shift+t", + function() + self:key_event("TEST") + end, + }, + { + "ctrl+shift+z", + function() + self:key_event("INVERT") + end, + }, + + { + "shift+left", + function() + self:key_event("NUDGE_LEFT") + end, + { repeatable = true }, + }, + { + "shift+up", + function() + self:key_event("NUDGE_UP") + end, + { repeatable = true }, + }, + { + "shift+right", + function() + self:key_event("NUDGE_RIGHT") + end, + { repeatable = true }, + }, + { + "shift+down", + function() + self:key_event("NUDGE_DOWN") + end, + { repeatable = true }, + }, + + { + "ctrl+left", + function() + self:key_event("GROW_LEFT") + end, + { repeatable = true }, + }, + { + "ctrl+up", + function() + self:key_event("GROW_TOP") + end, + { repeatable = true }, + }, + { + "ctrl+right", + function() + self:key_event("SHRINK_LEFT") + end, + { repeatable = true }, + }, + { + "ctrl+down", + function() + self:key_event("SHRINK_TOP") + end, + { repeatable = true }, + }, + + { + "ctrl+shift+left", + function() + self:key_event("SHRINK_RIGHT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+up", + function() + self:key_event("SHRINK_BOT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+right", + function() + self:key_event("GROW_RIGHT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+down", + function() + self:key_event("GROW_BOT") + end, + { repeatable = true }, + }, + + { + "ENTER", + function() + self:key_event("ENTER") + end, + }, + { + "ESC", + function() + self:key_event("ESC") + end, + }, + } + + self._keys_bound = false + + for k, v in pairs(self._key_binds) do + -- Insert a key name into the tables + table.insert(v, 2, self.keybind_group .. "_key_" .. v[1]) + end + + return self +end + +function ASSCropper:enable_key_bindings() + if not self._keys_bound then + for k, v in pairs(self._key_binds) do + mp.add_forced_key_binding(unpack(v)) + end + -- Clear "allow-vo-dragging" + mp.input_enable_section("input_forced_" .. mp.script_name) + self._keys_bound = true + end +end + +function ASSCropper:disable_key_bindings() + for k, v in pairs(self._key_binds) do + mp.remove_key_binding(v[2]) -- remove by name + end + self._keys_bound = false +end + +function ASSCropper:finalize_crop() + if self.current_crop ~= nil then + local x1, x2 = self.current_crop[1].x, self.current_crop[2].x + local y1, y2 = self.current_crop[1].y, self.current_crop[2].y + + self.current_crop.x, self.current_crop.y = x1, y1 + self.current_crop.w, self.current_crop.h = x2 - x1, y2 - y1 + + if self.options.even_dimensions then + self.current_crop.w = self.current_crop.w - (self.current_crop.w % 2) + self.current_crop.h = self.current_crop.h - (self.current_crop.h % 2) + end + + self.current_crop.x1, self.current_crop.x2 = x1, x1 + self.current_crop.w + self.current_crop.y1, self.current_crop.y2 = y1, y1 + self.current_crop.h + + self.current_crop[2].x, self.current_crop[2].y = self.current_crop.x2, self.current_crop.y2 + end +end + +function ASSCropper:key_event(name) + if name == "ENTER" then + self:stop_crop(false) + + self:finalize_crop() + + if self.callback_on_crop == nil then + mp.set_osd_ass(0, 0, "") + else + self.callback_on_crop(self.current_crop) + end + elseif name == "ESC" then + self:stop_crop(true) + + if self.callback_on_cancel == nil then + mp.set_osd_ass(0, 0, "") + else + self.callback_on_cancel() + end + elseif name == "TEST" then + self:toggle_testing() + elseif not self.testing_crop then + if name == "CROP_DETECT" then + self:toggle_crop_detect() + elseif name == "CROSSHAIR" then + self.options.draw_mouse = not self.options.draw_mouse + elseif name == "INVERT" then + self.options.color_invert = not self.options.color_invert + elseif name == "GUIDES" then + self.options.guide_type = (self.options.guide_type + 1) % self.guide_type_count + mp.osd_message(self.guide_type_names[self.options.guide_type]) + elseif self.nudges[name] then + self:nudge(true, unpack(self.nudges[name])) + elseif self.resizes[name] then + self:nudge(false, unpack(self.resizes[name])) + end + end +end + +function ASSCropper:nudge(keep_size, left, top, right, bottom) + if self.current_crop == nil then + return + end + + local x1, y1 = self.current_crop[1].x, self.current_crop[1].y + local x2, y2 = self.current_crop[2].x, self.current_crop[2].y + + local w, h = x2 - x1, y2 - y1 + if not keep_size then + w, h = 0, 0 + + if self.options.even_dimensions then + left = left * 2 + top = top * 2 + right = right * 2 + bottom = bottom * 2 + end + end + + local vw, vh = self.display_state.video.width, self.display_state.video.height + + x1 = math.max(0, math.min(vw - w, x1 + left)) + y1 = math.max(0, math.min(vh - h, y1 + top)) + + x2 = math.max(w, math.min(vw, x2 + right)) + y2 = math.max(h, math.min(vh, y2 + bottom)) + + local x_offset = math.max(0, 0 - x1) - math.max(0, x2 - vw) + local y_offset = math.max(0, 0 - y1) - math.max(0, y2 - vh) + + x1 = x1 + x_offset + y1 = y1 + y_offset + x2 = x2 + x_offset + y2 = y2 + y_offset + + self.current_crop[1].x, self.current_crop[2].x = order_pair(x1, x2) + self.current_crop[1].y, self.current_crop[2].y = order_pair(y1, y2) +end + +function ASSCropper:blackframe_stop() + if self.detecting_blackframe then + self.detecting_blackframe:stop() + self.detecting_blackframe = nil + + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.blackframe_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + end +end + +function ASSCropper:toggle_testing() + if self.testing_crop then + self:stop_testing() + else + self:start_testing() + end +end + +function ASSCropper:start_testing() + if not self.testing_crop then + local cw = self.current_crop and (self.current_crop[2].x - self.current_crop[1].x) or 0 + local ch = self.current_crop and (self.current_crop[2].y - self.current_crop[1].y) or 0 + + if cw == 0 or ch == 0 then + return mp.osd_message("Can't test current crop") + end + + self:cropdetect_stop() + self:blackframe_stop() + + local crop_filter = ("@%s:crop=w=%d:h=%d:x=%d:y=%d"):format( + self.crop_label, + cw, + ch, + self.current_crop[1].x, + self.current_crop[1].y + ) + local ret = mp.commandv("vf", "add", crop_filter) + if ret then + self.testing_crop = true + end + end +end + +function ASSCropper:stop_testing() + if self.testing_crop then + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.crop_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + self.testing_crop = false + end +end + +function ASSCropper:blackframe_check() + local blackframe_metadata = mp.get_property_native("vf-metadata/" .. self.blackframe_label) + local black_percentage = tonumber(blackframe_metadata["lavfi.blackframe.pblack"]) + + local now = mp.get_time() + if black_percentage ~= nil and now >= self.blackframe_wait then + self:blackframe_stop() + + self.options.color_invert = black_percentage < 50 + elseif now > self.blackframe_timeout then + -- Couldn't get blackframe metadata in time! + self:blackframe_stop() + end +end + +function ASSCropper:blackframe_start() + self:blackframe_stop() + if not self.detecting_blackframe then + local blackframe_filter = ("@%s:blackframe=amount=%d:threshold=%d"):format(self.blackframe_label, 0, 128) + + local ret = mp.commandv("vf", "add", blackframe_filter) + if ret then + self.blackframe_wait = mp.get_time() + 0.15 + self.blackframe_timeout = self.blackframe_wait + 1 + + self.detecting_blackframe = mp.add_periodic_timer(1 / 10, function() + self:blackframe_check() + end) + end + end +end + +function ASSCropper:cropdetect_stop() + if self.detecting_crop then + self.detecting_crop:stop() + self.detecting_crop = nil + self.cropdetect_wait = nil + self.cropdetect_timeout = nil + + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.cropdetect_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + end +end + +function ASSCropper:cropdetect_check() + local cropdetect_metadata = mp.get_property_native("vf-metadata/" .. self.cropdetect_label) + local get_n = function(s) + return tonumber(cropdetect_metadata["lavfi.cropdetect." .. s]) + end + + local now = mp.get_time() + if not isempty(cropdetect_metadata) and now >= self.cropdetect_wait then + self:cropdetect_stop() + + self.current_crop = { + { x = get_n("x1"), y = get_n("y1") }, + { x = get_n("x2") + 1, y = get_n("y2") + 1 }, + } + + mp.osd_message("Crop detected") + elseif now > self.cropdetect_timeout then + mp.osd_message("Crop detect timed out") + self:cropdetect_stop() + end +end + +function ASSCropper:toggle_crop_detect() + if self.detecting_crop then + self:cropdetect_stop() + mp.osd_message("Cancelled crop detect") + else + local cropdetect_filter = ("@%s:cropdetect=limit=%f:round=2:reset=0"):format(self.cropdetect_label, 30 / 255) + + local ret = mp.commandv("vf", "add", cropdetect_filter) + if not ret then + mp.osd_message("Crop detect failed") + else + self.cropdetect_wait = mp.get_time() + 0.2 + self.cropdetect_timeout = self.cropdetect_wait + 1.5 + + mp.osd_message("Starting automatic crop detect") + self.detecting_crop = mp.add_periodic_timer(1 / 10, function() + self:cropdetect_check() + end) + end + end +end + +function ASSCropper:start_crop(options, on_crop, on_cancel) + -- Refresh display state + self.display_state:recalculate_bounds(true) + if self.display_state.video_ready then + self.active = true + self.tick_timer:resume() + + self.options = {} + + for k, v in pairs(self.default_options) do + self.options[k] = v + end + for k, v in pairs(options or {}) do + self.options[k] = v + end + + self.callback_on_crop = on_crop + self.callback_on_cancel = on_cancel + + self.dragging = 0 + + self:enable_key_bindings() + self:update_mouse_position() + + if self.options.auto_invert then + self:blackframe_start() + end + end +end + +function ASSCropper:stop_crop(clear) + self.active = false + self.tick_timer:stop() + + self:cropdetect_stop() + self:blackframe_stop() + self:stop_testing() + + self:disable_key_bindings() + if clear then + self.current_crop = nil + end +end + +function ASSCropper:on_tick() + -- Unused, for debugging + if self.active then + self.display_state:recalculate_bounds() + self:render() + end +end + +function ASSCropper:update_mouse_position() + -- These are real on-screen coords. + self.mouse_screen.x, self.mouse_screen.y = mp.get_mouse_pos() + + if self.display_state:recalculate_bounds() and self.display_state.video_ready then + -- These are on-video coords. + local mx, my = self.display_state:screen_to_video(self.mouse_screen.x, self.mouse_screen.y) + self.mouse_video.x = mx + self.mouse_video.y = my + end +end + +function ASSCropper:get_hitboxes(crop_box) + crop_box = crop_box or self.current_crop + if crop_box == nil then + return nil + end + + local x1, x2 = order_pair(crop_box[1].x, crop_box[2].x) + local y1, y2 = order_pair(crop_box[1].y, crop_box[2].y) + local w, h = math.abs(x2 - x1), math.abs(y2 - y1) + + -- Corner and required corner size in videospace pixels + local mult = math.min(self.display_state.scale.x, self.display_state.scale.y) + local videospace_corner_size = self.corner_size * mult + local videospace_required_size = self.corner_required_size * mult + + local handles_outside = (math.min(w, h) <= videospace_required_size) + + local hitbox_bases = { + { x1, y2, x1, y2 }, -- BL + { x1, y2, x2, y2 }, -- B + { x2, y2, x2, y2 }, -- BR + + { x1, y1, x1, y2 }, -- L + { x1, y1, x2, y2 }, -- Center + { x2, y1, x2, y2 }, -- R + + { x1, y1, x1, y1 }, -- TL + { x1, y1, x2, y1 }, -- T + { x2, y1, x2, y1 }, -- TR + } + + local hitbox_mults + if handles_outside then + hitbox_mults = { + { -1, 0, 0, 1 }, + { 0, 0, 0, 1 }, + { 0, 0, 1, 1 }, + + { -1, 0, 0, 0 }, + { 0, 0, 0, 0 }, + { 0, 0, 1, 0 }, + + { -1, -1, 0, 0 }, + { 0, -1, 0, 0 }, + { 0, -1, 1, 0 }, + } + else + hitbox_mults = { + { 0, -1, 1, 0 }, + { 1, -1, -1, 0 }, + { -1, -1, 0, 0 }, + + { 0, 1, 1, -1 }, + { 1, 1, -1, -1 }, + { -1, 1, 0, -1 }, + + { 0, 0, 1, 1 }, + { 1, 0, -1, 1 }, + { -1, 0, 0, 1 }, + } + end + + local hitboxes = {} + for index, hitbox_base in ipairs(hitbox_bases) do + local hitbox_mult = hitbox_mults[index] + + hitboxes[index] = { + hitbox_base[1] + hitbox_mult[1] * videospace_corner_size, + hitbox_base[2] + hitbox_mult[2] * videospace_corner_size, + hitbox_base[3] + hitbox_mult[3] * videospace_corner_size, + hitbox_base[4] + hitbox_mult[4] * videospace_corner_size, + } + end + -- Pseudobox to easily pass the original crop box + hitboxes[10] = { x1, y1, x2, y2 } + + return hitboxes +end + +function ASSCropper:hit_test(hitboxes, position) + if hitboxes == nil then + return 0 + else + local px, py = position.x, position.y + + for i = 1, 9 do + local hb = hitboxes[i] + + if (px >= hb[1] and px < hb[3]) and (py >= hb[2] and py < hb[4]) then + return i + end + end + -- No hits + return 0 + end +end + +function ASSCropper:on_mouse(button, event, shift_down) + if not (event.event == "up" or event.event == "down") then + return + end + mouse_down = event.event == "down" + shift_down = shift_down or false + + if button == "mouse_btn0" and self.active and not self.detecting_crop and not self.testing_crop then + local mouse_pos = { x = self.mouse_video.x, y = self.mouse_video.y } + + -- Helpers + local xy_same = function(a, b) + return a.x == b.x and a.y == b.y + end + local xy_distance = function(a, b) + local dx = a.x - b.x + local dy = a.y - b.y + return math.sqrt(dx * dx + dy * dy) + end + -- + + if mouse_down then -- Mouse pressed + local bound_mouse_pos = { + x = math.max(0, math.min(self.display_state.video.width, mouse_pos.x)), + y = math.max(0, math.min(self.display_state.video.height, mouse_pos.y)), + } + + if self.current_crop == nil then + self.current_crop = { bound_mouse_pos, bound_mouse_pos } + + self.dragging = 3 + self.anchor_pos = { bound_mouse_pos.x, bound_mouse_pos.y } + + self.crop_ratio = 1 + self.drag_start = bound_mouse_pos + + local handle_pos = self:_get_anchor_positions()[hit] + self.drag_offset = { 0, 0 } + + self.restrict_ratio = shift_down + elseif self.dragging == 0 then + -- Check if we drag from a handle + local hitboxes = self:get_hitboxes() + local hit = self:hit_test(hitboxes, mouse_pos) + + self.dragging = hit + self.anchor_pos = self:_get_anchor_positions()[10 - hit] + + self.crop_ratio = (hitboxes[10][3] - hitboxes[10][1]) / (hitboxes[10][4] - hitboxes[10][2]) + self.drag_start = mouse_pos + + local handle_pos = self:_get_anchor_positions()[hit] or { mouse_pos.x, mouse_pos.y } + self.drag_offset = { mouse_pos.x - handle_pos[1], mouse_pos.y - handle_pos[2] } + + self.restrict_ratio = shift_down + + -- Start a new drag if not on handle + if self.dragging == 0 then + self.current_crop = { bound_mouse_pos, bound_mouse_pos } + self.crop_ratio = 1 + + self.dragging = 3 + self.anchor_pos = { bound_mouse_pos.x, bound_mouse_pos.y } + -- self.drag_start = mouse_pos + end + end + else -- Mouse released + if + xy_same(self.current_crop[1], self.current_crop[2]) + and xy_distance(self.current_crop[1], mouse_pos) < 5 + then + -- Mouse released after first click - ignore + elseif self.dragging > 0 then + -- Adjust current crop + self.current_crop = self:offset_crop_by_drag() + self.dragging = 0 + end + end + end +end + +function ASSCropper:_get_anchor_positions() + local x1, y1 = self.current_crop[1].x, self.current_crop[1].y + local x2, y2 = self.current_crop[2].x, self.current_crop[2].y + return { + [1] = { x1, y2 }, + [2] = { (x1 + x2) / 2, y2 }, + [3] = { x2, y2 }, + + [4] = { x1, (y1 + y2) / 2 }, + [5] = { (x1 + x2) / 2, (y1 + y2) / 2 }, + [6] = { x2, (y1 + y2) / 2 }, + + [7] = { x1, y1 }, + [8] = { (x1 + x2) / 2, y1 }, + [9] = { x2, y1 }, + } +end + +function ASSCropper:offset_crop_by_drag() + -- Here be dragons lol + local vw, vh = self.display_state.video.width, self.display_state.video.height + local mx, my = self.mouse_video.x, self.mouse_video.y + + local x1, x2 = self.current_crop[1].x, self.current_crop[2].x + local y1, y2 = self.current_crop[1].y, self.current_crop[2].y + + local anchor_positions = self:_get_anchor_positions() + + local handle = self.dragging + if handle > 0 then + local ax, ay = self.anchor_pos[1], self.anchor_pos[2] + + local ox, oy = self.drag_offset[1], self.drag_offset[2] + + local dx, dy = mx - ax - ox, my - ay - oy + + -- Select active corner + if handle % 2 == 1 and handle ~= 5 then -- Change corners 4/6, 2/8 + handle = (mx - ox < ax) and 1 or 3 + handle = handle + ((my - oy < ay) and 6 or 0) + else -- Change edges 1, 3, 7, 9 + if handle == 4 and mx - ox > ax then + handle = 6 + elseif handle == 6 and mx - ox < ax then + handle = 4 + elseif handle == 2 and my - oy < ay then + handle = 8 + elseif handle == 8 and my - oy > ay then + handle = 2 + end + end + + -- Handle booleans for logic + local h_bot = handle >= 1 and handle <= 3 + local h_top = handle >= 7 and handle <= 9 + local h_left = (handle - 1) % 3 == 0 + local h_right = handle % 3 == 0 + + local h_horiz = handle == 4 or handle == 6 + local h_vert = handle == 2 or handle == 8 + + -- Keep rect aspect ratio + if self.restrict_ratio then + local adx, ady = math.abs(dx), math.abs(dy) + + -- Fit rect to mouse + local tmpy = adx / self.crop_ratio + if tmpy < ady then + adx = ady * self.crop_ratio + else + ady = tmpy + end + + -- Figure out max size for corners, limit adx/ady + local max_w, max_h = vw, vh + + if h_bot then + max_h = vh - ay -- Max height is from anchor to video bottom + elseif h_top then + max_h = ay -- Max height is from video bottom to anchor + elseif h_horiz then + -- Max height is closest edge * 2 + max_h = math.min(vh - ay, ay) * 2 + end + + if h_left then + max_w = ax + elseif h_right then + max_w = vw - ax + elseif h_vert then + max_w = math.min(vw - ax, ax) * 2 + end + + -- Limit size to corners + if handle ~= 5 then + -- TODO this can be done tidier? + + -- If wider than max width, scale down + if adx > max_w then + adx = max_w + ady = adx / self.crop_ratio + end + -- If taller than max height, scale down + if ady > max_h then + ady = max_h + adx = ady * self.crop_ratio + end + end + + -- Hacky offsets + if handle == 1 then + dx = -adx + dy = ady + elseif handle == 2 then + dx = adx + dy = ady + elseif handle == 3 then + dx = adx + dy = ady + elseif handle == 4 then + dx = -adx + dy = ady + elseif handle == 5 then + -- pass + elseif handle == 6 then + dx = adx + dy = ady + elseif handle == 7 then + dy = -ady + dx = -adx + elseif handle == 8 then + dx = adx + dy = -ady + elseif handle == 9 then + dx = adx + dy = -ady + end + end + + -- Can this be done not-manually? + -- Re-create the rect with some corners anchored etc + if handle == 5 then + -- Simply move the box around + x1, x2 = x1 + dx, x2 + dx + y1, y2 = y1 + dy, y2 + dy + elseif handle == 1 then + x1, x2 = ax + dx, ax + y1, y2 = ay, ay + dy + elseif handle == 2 then + y1, y2 = ay, ay + dy + + if self.restrict_ratio then + x1, x2 = ax - dx / 2, ax + dx / 2 + end + elseif handle == 3 then + x1, x2 = ax, ax + dx + y1, y2 = ay, ay + dy + elseif handle == 4 then + x1, x2 = ax + dx, ax + + if self.restrict_ratio then + y1, y2 = ay - dy / 2, ay + dy / 2 + end + elseif handle == 6 then + x1, x2 = ax, ax + dx + + if self.restrict_ratio then + y1, y2 = ay - dy / 2, ay + dy / 2 + end + elseif handle == 7 then + x1, x2 = ax + dx, ax + y1, y2 = ay + dy, ay + elseif handle == 8 then + y1, y2 = ay + dy, ay + + if self.restrict_ratio then + x1, x2 = ax - dx / 2, ax + dx / 2 + end + elseif handle == 9 then + x1, x2 = ax, ax + dx + y1, y2 = ay + dy, ay + end + + if self.dragging == 5 then + -- On moving the entire box, we have to figure out how much to "offset" every corner if we go over the edge + local x_min = math.max(0, 0 - x1) + local y_min = math.max(0, 0 - y1) + + local x_max = math.max(0, x2 - vw) + local y_max = math.max(0, y2 - vh) + + x1 = x1 + x_min - x_max + y1 = y1 + y_min - y_max + x2 = x2 + x_min - x_max + y2 = y2 + y_min - y_max + elseif not self.restrict_ratio then + -- This is already done for restricted ratios, hence the if + + -- Constrict the crop to video space + -- Since one corner/edge is moved at a time, we can just minmax this + x1, x2 = math.max(0, x1), math.min(vw, x2) + y1, y2 = math.max(0, y1), math.min(vh, y2) + end + end -- /drag + + if self.dragging > 0 and self.options.even_dimensions then + local w, h = x2 - x1, y2 - y1 + local even_w = w - (w % 2) + local even_h = h - (h % 2) + + if handle == 1 or handle == 2 or handle == 3 then + y2 = y1 + even_h + elseif handle == 7 or handle == 8 or handle == 9 then + y1 = y2 - even_h + end + if handle == 1 or handle == 4 or handle == 7 then + x1 = x2 - even_w + elseif handle == 3 or handle == 6 or handle == 9 then + x2 = x1 + even_w + end + end + + local fx1, fx2 = order_pair(math.floor(x1), math.floor(x2)) + local fy1, fy2 = order_pair(math.floor(y1), math.floor(y2)) + + -- msg.info(fx1, fy1, fx2, fy2, handle) + + return { { x = fx1, y = fy1 }, { x = fx2, y = fy2 } }, handle +end + +function order_pair(a, b) + if a < b then + return a, b + else + return b, a + end +end + +function ASSCropper:render() + -- For debugging + local ass_txt = self:get_render_ass() + + local ds = self.display_state + mp.set_osd_ass(ds.screen.width, ds.screen.height, ass_txt) +end + +function ASSCropper:get_render_ass(dim_only) + if not self.display_state.video_ready then + msg.info("No video info on display_state") + return "" + end + + line_color = self.options.color_invert and 20 or 220 + local guide_format = string.format( + "{\\3a&HFF&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1\\shad0}", + 128, + line_color, + line_color, + line_color + ) + + ass = assdraw.ass_new() + if self.current_crop then + if self.testing_crop then + -- Just draw simple help + ass:new_event() + ass:pos(self.display_state.screen.width - 5, 5) + ass:append(string.format("{\\fs%d\\an%d\\bord2}", self.text_size, 9)) + + local fmt_key = function(key, text) + return string.format("[{\\c&HBEBEBE&}%s{\\c} %s]", key:upper(), text) + end + + ass:append( + fmt_key("ENTER", "Accept crop") + .. " " + .. fmt_key("ESC", "Cancel crop") + .. "\\N" + .. fmt_key("T", "Stop testing") + ) + return ass.text + end + + local temp_crop, drawn_handle = self:offset_crop_by_drag() + local v_hb = self:get_hitboxes(temp_crop) + -- Map coords to screen + local s_hb = {} + for index, coords in pairs(v_hb) do + local x1, y1 = self.display_state:video_to_screen(coords[1], coords[2]) + local x2, y2 = self.display_state:video_to_screen(coords[3], coords[4]) + s_hb[index] = { x1, y1, x2, y2 } + end + + -- Full crop + local v_crop = v_hb[10] -- Video-space + local s_crop = s_hb[10] -- Screen-space + + -- Inverse clipping for the crop box + ass:new_event() + ass:append(string.format("{\\iclip(%d,%d,%d,%d)}", s_crop[1], s_crop[2], s_crop[3], s_crop[4])) + + -- Dim overlay + local format_dim = string.format( + "{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", + self.overlay_transparency, + self.overlay_lightness, + self.overlay_lightness, + self.overlay_lightness + ) + ass:pos(0, 0) + ass:draw_start() + ass:append(format_dim) + ass:rect_cw(0, 0, self.display_state.screen.width, self.display_state.screen.height) + ass:draw_stop() + + if dim_only then -- Early out with just the dim outline + return ass.text + end + + if draw_text then + -- Text on end + ass:new_event() + ass:pos(ce_x, ce_y) + -- Text align + local txt_a = ((ce_x > cs_x) and 3 or 1) + ((ce_y > cs_y) and 0 or 6) + ass:an(txt_a) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%dx%d", math.abs(ce_x - cs_x), math.abs(ce_y - cs_y))) + end + + local box_format = + string.format("{\\1a&HFF&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1}", 0, line_color, line_color, line_color) + local handle_hilight_format = string.format( + "{\\1a&H%02X&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord0}", + 230, + 0, + line_color, + line_color, + line_color + ) + local handle_drag_format = string.format( + "{\\1a&H%02X&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1}", + 200, + 0, + line_color, + line_color, + line_color + ) + + -- Main crop box + ass:new_event() + ass:pos(0, 0) + ass:append(box_format) + ass:draw_start() + ass:rect_cw(s_crop[1], s_crop[2], s_crop[3], s_crop[4]) + ass:draw_stop() + + -- Guide grid, 3x3 + if self.options.guide_type then + ass:new_event() + ass:pos(0, 0) + ass:append(guide_format) + ass:draw_start() + + local w = (s_crop[3] - s_crop[1]) + local h = (s_crop[4] - s_crop[2]) + + local w_3rd = w / 3 + local h_3rd = h / 3 + local w_2 = w / 2 + local h_2 = h / 2 + if self.options.guide_type == 1 then + -- 3x3 grid + ass:move_to(s_crop[1] + w_3rd, s_crop[2]) + ass:line_to(s_crop[1] + w_3rd, s_crop[4]) + + ass:move_to(s_crop[1] + w_3rd * 2, s_crop[2]) + ass:line_to(s_crop[1] + w_3rd * 2, s_crop[4]) + + ass:move_to(s_crop[1], s_crop[2] + h_3rd) + ass:line_to(s_crop[3], s_crop[2] + h_3rd) + + ass:move_to(s_crop[1], s_crop[2] + h_3rd * 2) + ass:line_to(s_crop[3], s_crop[2] + h_3rd * 2) + elseif self.options.guide_type == 2 then + -- Top to bottom + ass:move_to(s_crop[1] + w_2, s_crop[2]) + ass:line_to(s_crop[1] + w_2, s_crop[4]) + + -- Left to right + ass:move_to(s_crop[1], s_crop[2] + h_2) + ass:line_to(s_crop[3], s_crop[2] + h_2) + end + ass:draw_stop() + end + + if self.dragging > 0 and drawn_handle ~= 5 then + -- While dragging, draw only the dragging handle + ass:new_event() + ass:append(handle_drag_format) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(s_hb[drawn_handle][1], s_hb[drawn_handle][2], s_hb[drawn_handle][3], s_hb[drawn_handle][4]) + ass:draw_stop() + elseif self.dragging == 0 then + local hit_index = self:hit_test(s_hb, self.mouse_screen) + if hit_index > 0 and hit_index ~= 5 then + -- Hilight handle + ass:new_event() + ass:append(handle_hilight_format) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(s_hb[hit_index][1], s_hb[hit_index][2], s_hb[hit_index][3], s_hb[hit_index][4]) + ass:draw_stop() + end + + ass:new_event() + ass:pos(0, 0) + ass:append(box_format) + ass:draw_start() + + -- Draw corner handles + for k, v in pairs({ 1, 3, 7, 9 }) do + ass:rect_cw(s_hb[v][1], s_hb[v][2], s_hb[v][3], s_hb[v][4]) + end + ass:draw_stop() + end + + if true or draw_text then + local br_pos = { s_crop[3] - 2, s_crop[4] + 2 } + local br_align = 9 + if br_pos[2] >= self.display_state.screen.height - 20 then + br_pos[2] = br_pos[2] - 4 + br_align = 3 + end + + ass:new_event() + ass:pos(unpack(br_pos)) + ass:an(br_align) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%dx%d", v_crop[3] - v_crop[1], v_crop[4] - v_crop[2])) + + local tl_pos = { s_crop[1] + 2, s_crop[2] - 2 } + local tl_align = 1 + if tl_pos[2] < 20 then + tl_pos[2] = tl_pos[2] + 4 + tl_align = 7 + end + + ass:new_event() + ass:pos(unpack(tl_pos)) + ass:an(tl_align) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%d,%d", v_crop[1], v_crop[2])) + end + + ass:draw_stop() + end + + -- Crosshair for mouse + if self.options.draw_mouse and not dim_only then + ass:new_event() + ass:pos(0, 0) + ass:append(guide_format) + ass:draw_start() + + ass:move_to(self.mouse_screen.x, 0) + ass:line_to(self.mouse_screen.x, self.display_state.screen.height) + + ass:move_to(0, self.mouse_screen.y) + ass:line_to(self.display_state.screen.width, self.mouse_screen.y) + + ass:draw_stop() + end + + if self.options.draw_help and not dim_only then + ass:new_event() + ass:pos(self.display_state.screen.width - 5, 5) + local text_align = 9 + ass:append(string.format("{\\fs%d\\an%d\\bord2}", self.text_size, text_align)) + + local fmt_key = function(key, text) + return string.format("[{\\c&HBEBEBE&}%s{\\c} %s]", key:upper(), text) + end + + local crosshair_txt = self.options.draw_mouse and "Hide" or "Show" + lines = { + fmt_key("ENTER", "Accept crop") .. " " .. fmt_key("ESC", "Cancel crop") .. " " .. fmt_key( + "^D", + "Autodetect crop" + ) .. " " .. fmt_key("^T", "Test crop"), + fmt_key("SHIFT-Drag", "Constrain ratio") .. " " .. fmt_key("SHIFT-Arrow", "Nudge"), + fmt_key("^C", crosshair_txt .. " crosshair") .. " " .. fmt_key("^X", "Cycle guides") .. " " .. fmt_key( + "^Z", + "Invert color" + ), + } + + local full_line = nil + for i, line in pairs(lines) do + if line ~= nil then + full_line = full_line and (full_line .. "\\N" .. line) or line + end + end + ass:append(full_line) + end + + return ass.text +end +--[[ + A tool to expand properties in template strings, mimicking mpv's + property expansion but with a few extras (like formatting times). + + Depends on helpers.lua (isempty) +]] +-- + +local PropertyExpander = {} +PropertyExpander.__index = PropertyExpander + +setmetatable(PropertyExpander, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function PropertyExpander.new(property_source) + local self = setmetatable({}, PropertyExpander) + self.sentinel = {} + + -- property_source is a table which defines the following functions: + -- get_raw_property(name, def) - returns a raw property or def + -- get_property(name) - returns a string + -- get_property_osd(name) - returns an OSD formatted string (whatever that'll mean) + self.property_source = property_source + return self +end + +-- Formats seconds to H:M:S based on a %h-%m-%s format +function PropertyExpander:_format_time(seconds, time_format) + -- In case "seconds" is not a number, give it back + if type(seconds) ~= "number" then + return seconds + end + + time_format = time_format or "%02h.%02m.%06.3s" + + local types = { h = "d", m = "d", s = "f", S = "f", M = "d" } + local values = { + h = math.floor(seconds / 3600), + m = math.floor((seconds % 3600) / 60), + s = (seconds % 60), + S = seconds, + M = math.floor((seconds % 1) * 1000), + } + + local substitutor = function(sub_format, char) + local v = values[char] + local t = types[char] + if t == nil then + return nil + end + + sub_format = "%" .. sub_format .. types[char] + return v and sub_format:format(v) or nil + end + + return time_format:gsub("%%([%-%+ #0]*%d*.?%d*)([%a%%])", substitutor) +end + +-- Format a date +function PropertyExpander:_format_date(seconds, date_format) + -- In case "seconds" is not nil or a number, give it back + if type(seconds) ~= "number" and type(seconds) ~= "nil" then + return seconds + end + + --[[ + As stated by Lua docs: + %a abbreviated weekday name (e.g., Wed) + %A full weekday name (e.g., Wednesday) + %b abbreviated month name (e.g., Sep) + %B full month name (e.g., September) + %c date and time (e.g., 09/16/98 23:48:10) + %d day of the month (16) [01-31] + %H hour, using a 24-hour clock (23) [00-23] + %I hour, using a 12-hour clock (11) [01-12] + %M minute (48) [00-59] + %m month (09) [01-12] + %p either "am" or "pm" (pm) + %S second (10) [00-61] + %w weekday (3) [0-6 = Sunday-Saturday] + %x date (e.g., 09/16/98) + %X time (e.g., 23:48:10) + %Y full year (1998) + %y two-digit year (98) [00-99] + %% the character `%´ + ]] + -- + date_format = date_format or "%Y-%m-%d %H-%M-%S" + return os.date(date_format, seconds) +end + +function PropertyExpander:expand(format_string) + local comparisons = { + { + -- Less than or equal + "^(..-)<=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value <= tonumber(other_value) + end, + }, + { + -- More than or equal + "^(..-)>=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value >= tonumber(other_value) + end, + }, + { + -- Less than + "^(..-)<(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value < tonumber(other_value) + end, + }, + { + -- More than + "^(..-)>(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value > tonumber(other_value) + end, + }, + { + -- Equal + "^(..-)==(.+)$", + function(property_value, other_value) + if type(property_value) == "number" then + other_value = tonumber(other_value) + elseif type(property_value) ~= "string" then + -- Ignore booleans and others + return nil + end + return property_value == other_value + end, + }, + { + -- Starts with + "^(..-)^=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return property_value:sub(1, other_value:len()) == other_value + end, + }, + { + -- Ends with + "^(..-)$=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return other_value == "" or property_value:sub(-other_value:len()) == other_value + end, + }, + { + -- Contains + "^(..-)~=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return property_value:find(other_value, nil, true) ~= nil + end, + }, + } + + local substitutor = function(match) + local command, inner = match:sub(3, -2):match("^([%?!~^%%#&]?)(.+)$") + local colon_index = inner:find(":") + + local property_name = inner + local secondary = "" + local has_colon = colon_index and true or false + + if colon_index then + property_name = inner:sub(1, colon_index - 1) + secondary = inner:sub(colon_index + 1, -1) + end + + local used_comparison = nil + local comparison_value = nil + for i, comparison in ipairs(comparisons) do + local name, other_value = property_name:match(comparison[1]) + if name then + property_name = name + comparison_value = other_value + used_comparison = comparison[2] + break + end + end + + local raw_property_value = self.property_source:get_raw_property(property_name, self.sentinel) + local property_exists = raw_property_value ~= self.sentinel + + if command == "" then + if used_comparison then + if used_comparison(raw_property_value, comparison_value) then + return self:expand(secondary) + else + return "" + end + end + + -- Return the property value if it's not nil, else the (expanded) secondary + return property_exists and self.property_source:get_property(property_name) or self:expand(secondary) + elseif command == "?" then + -- Return (expanded) secondary if property is truthy (sentinel is falsey) + if not isempty(raw_property_value) then + return self:expand(secondary) + else + return "" + end + elseif command == "!" then + if used_comparison then + if not used_comparison(raw_property_value, comparison_value) then + return self:expand(secondary) + else + return "" + end + end + + -- Return (expanded) secondary if property is falsey + if isempty(raw_property_value) then + return self:expand(secondary) + else + return "" + end + elseif command == "^" then + -- Return (expanded) secondary if property does not exist + return not property_exists and self:expand(secondary) or "" + elseif command == "%" then + -- Return the value formatted using the secondary string + return secondary:format(raw_property_value) + elseif command == "#" then + -- Format a number to HMS + return self:_format_time(raw_property_value, has_colon and secondary or nil) + elseif command == "&" then + -- Format a date + return self:_format_date(nil, has_colon and secondary or nil) + elseif command == "@" then + -- Format the value for OSD - mostly useful for latching onto mpv's properties + return property_exists and self.property_source:get_property_osd(property_name) or self:expand(secondary) + end + end + + -- Lua patterns are generally a pain, but %b is comfy! + local expanded = format_string:gsub("%$%b{}", substitutor) + return expanded +end + +local MPVPropertySource = {} +MPVPropertySource.__index = MPVPropertySource + +setmetatable(MPVPropertySource, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function MPVPropertySource.new(values) + local self = setmetatable({}, MPVPropertySource) + self.values = values + + return self +end + +function MPVPropertySource:get_raw_property(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property_native(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return v + else + return default + end +end + +function MPVPropertySource:get_property(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return tostring(v) + else + return default + end +end + +function MPVPropertySource:get_property_osd(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property_osd(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return tostring(v) + else + return default + end +end +function script_crop_toggle() + if asscropper.active then + asscropper:stop_crop(true) + else + local on_crop = function(crop) + mp.set_osd_ass(0, 0, "") + screenshot(crop) + end + local on_cancel = function() + mp.osd_message("Crop canceled") + mp.set_osd_ass(0, 0, "") + end + + local crop_options = { + guide_type = ({ none = 0, grid = 1, center = 2 })[option_values.guide_type], + draw_mouse = option_values.draw_mouse, + color_invert = option_values.color_invert, + auto_invert = option_values.auto_invert, + } + asscropper:start_crop(crop_options, on_crop, on_cancel) + if not asscropper.active then + mp.osd_message("No video to crop!", 2) + end + end +end + +local next_tick_time = nil +function on_tick_listener() + local now = mp.get_time() + if next_tick_time == nil or now >= next_tick_time then + if asscropper.active and display_state:recalculate_bounds() then + mp.set_osd_ass(display_state.screen.width, display_state.screen.height, asscropper:get_render_ass()) + end + next_tick_time = now + (1 / 60) + end +end + +local function resolve_path(path) + -- Expand tilde (~) to home directory + local home = os.getenv("HOME") + if home and path:sub(1, 2) == "~/" then + path = home .. path:sub(2) + end + -- Convert relative paths to absolute paths + return mp.command_native({ "expand-path", path }) +end + +function expand_output_path(cropbox) + local filename = mp.get_property_native("filename") + local playback_time = mp.get_property_native("playback-time") + local duration = mp.get_property_native("duration") + + local filename_without_ext, extension = filename:match("^(.+)%.(.-)$") + + local properties = { + path = mp.get_property_native("path"), -- Original path + + filename = filename_without_ext or filename, -- Filename without extension (or filename if no dots) + file_ext = extension or "", -- Original extension without leading dot (or empty string) + + pos = playback_time, + + full = false, + is_image = (duration == 0 and playback_time == 0), + + crop_w = cropbox.w, + crop_h = cropbox.h, + crop_x = cropbox.x, + crop_y = cropbox.y, + crop_x2 = cropbox.x2, + crop_y2 = cropbox.y2, + + unique = 0, + + ext = option_values.output_extension, + } + + local propex = PropertyExpander(MPVPropertySource(properties)) + + local test_path = resolve_path(propex:expand(option_values.output_template)) + -- If the paths do not change when incrementing the unique, it's not used. + -- Return early and avoid the endless loop + properties.unique = 1 + if resolve_path(propex:expand(option_values.output_template)) == test_path then + properties.full = true + local temporary_screenshot_path = resolve_path(propex:expand(option_values.output_template)) + return test_path, temporary_screenshot_path + else + -- Figure out a unique filename + while true do + test_path = resolve_path(propex:expand(option_values.output_template)) + + -- Check if filename is free + if not path_exists(test_path) then + properties.full = true + local temporary_screenshot_path = resolve_path(propex:expand(option_values.output_template)) + return test_path, temporary_screenshot_path + else + -- Try the next one + properties.unique = properties.unique + 1 + end + end + end +end + +function screenshot(crop) + local size = round_dec(crop.w) .. "x" .. round_dec(crop.h) + + -- Bail on bad crop sizes + if not (crop.w > 0 and crop.h > 0) then + mp.osd_message("Bad crop (" .. size .. ")!") + return + end + + local output_path, temporary_screenshot_path = expand_output_path(crop) + + -- Optionally create directories + if option_values.create_directories then + local paths = {} + paths[1] = path_utils.dirname(output_path) + paths[2] = path_utils.dirname(temporary_screenshot_path) + + -- Check if we can read the paths + for i, path in ipairs(paths) do + local l, err = utils.readdir(path) + if err then + create_directories(path) + end + end + end + + local playback_time = mp.get_property_native("playback-time") + local duration = mp.get_property_native("duration") + + local input_path = nil + + if option_values.skip_screenshot_for_images and duration == 0 and playback_time == 0 then + -- Seems to be an image (or at least static file) + input_path = mp.get_property_native("path") + temporary_screenshot_path = nil + else + -- Not an image, take a temporary screenshot + + -- In case the full-size output path is identical to the crop path, + -- crudely make it different + if temporary_screenshot_path == output_path then + temporary_screenshot_path = temporary_screenshot_path .. "_full.png" + end + + -- Temporarily lower the PNG compression + local previous_png_compression = mp.get_property_native("screenshot-png-compression") + mp.set_property_native("screenshot-png-compression", 0) + -- Take the screenshot + mp.commandv("raw", "no-osd", "screenshot-to-file", temporary_screenshot_path) + -- Return the previous value + mp.set_property_native("screenshot-png-compression", previous_png_compression) + + if not path_exists(temporary_screenshot_path) then + msg.error("Failed to take screenshot: " .. temporary_screenshot_path) + mp.osd_message("Unable to save screenshot") + return + end + + input_path = temporary_screenshot_path + end + + local crop_string = string.format("%d:%d:%d:%d", crop.w, crop.h, crop.x, crop.y) + local cmd = { + args = { + "mpv", + input_path, + "--no-config", + "--vf=crop=" .. crop_string, + "--frames=1", + "--ovc=" .. option_values.output_format, + "-o", + output_path, + }, + } + + msg.info("Cropping: ", crop_string, output_path) + local ret = utils.subprocess(cmd) + + if not option_values.keep_original and temporary_screenshot_path then + os.remove(temporary_screenshot_path) + end + + if ret.error or ret.status ~= 0 then + mp.osd_message("Screenshot failed, see console for details") + msg.error("Crop failed! mpv exit code: " .. tostring(ret.status)) + msg.error("mpv stdout:") + msg.error(ret.stdout) + else + msg.info("Crop finished!") + mp.osd_message("Took screenshot (" .. size .. ")") + end +end + +---------------------- +-- Instances, binds -- +---------------------- + +-- Sanity-check output_template +if option_values.warn_about_template and not option_values.output_template:find("%${ext}") then + msg.warn("Output template missing ${ext}! If this is desired, set warn_about_template=yes in config!") +end + +-- Short list of extensions for encoders +local ENCODER_EXTENSION_MAP = { + png = "png", + mjpeg = "jpg", + targa = "tga", + tiff = "tiff", + gif = "gif", -- please don't + bmp = "bmp", + jpegls = "jpg", + ljpeg = "jpg", + jpeg2000 = "jp2", +} +-- Pick an extension if one was not provided +if option_values.output_extension == "" then + local extension = ENCODER_EXTENSION_MAP[option_values.output_format] + if not extension then + msg.error( + "Unrecognized output format '" .. option_values.output_format .. "', unable to pick an extension! Bailing!" + ) + mp.osd_message("mpv_crop_script was unable to choose an extension, check your config", 3) + end + option_values.output_extension = extension +end + +display_state = DisplayState() +asscropper = ASSCropper(display_state) +asscropper.overlay_transparency = option_values.overlay_transparency +asscropper.overlay_lightness = option_values.overlay_lightness + +asscropper.tick_callback = on_tick_listener +mp.register_event("tick", on_tick_listener) + +local used_keybind = SCRIPT_KEYBIND +-- Disable the default keybind if asked to +if option_values.disable_keybind then + used_keybind = nil +end +mp.add_key_binding(used_keybind, SCRIPT_HANDLER, script_crop_toggle) diff --git a/mac/.config/mpv/scripts/navigator.lua b/mac/.config/mpv/scripts/navigator.lua new file mode 100644 index 0000000..4533f60 --- /dev/null +++ b/mac/.config/mpv/scripts/navigator.lua @@ -0,0 +1,605 @@ +local utils = require("mp.utils") +local mpopts = require("mp.options") +local assdraw = require("mp.assdraw") +local user = os.getenv("USER") or "si" +local home = os.getenv("HOME") or ("/home/" .. user) + +ON_WINDOWS = (package.config:sub(1, 1) ~= "/") +WINDOWS_ROOTDIR = false +WINDOWS_ROOT_DESC = "Select drive" +SEPARATOR_WINDOWS = "\\" + +SEPARATOR = "/" + +local windows_desktop = ON_WINDOWS + and utils.join_path(os.getenv("USERPROFILE"), "Desktop"):gsub(SEPARATOR, SEPARATOR_WINDOWS) .. SEPARATOR_WINDOWS + or nil + +local settings = { + --navigation keybinds override arrowkeys and enter when activating navigation menu, false means keys are always actíve + dynamic_binds = true, + navigator_mainkey = "shift+v", --the key to bring up navigator's menu, can be bound on input.conf aswell + + --dynamic binds, should not be bound in input.conf unless dynamic binds is false + key_navfavorites = "ctrl+f", + key_navup = "k", + key_navdown = "j", + key_navback = "h", + key_navforward = "l", + key_navopen = "ENTER", + key_navclose = "q", + + --fallback if no file is open, should be a string that points to a path in your system + defaultpath = windows_desktop or os.getenv("HOME") or "/", + forcedefault = false, --force navigation to start from defaultpath instead of currently playing file + --favorites in format { 'Path to directory, notice trailing /' } + --on windows use double backslash c:\\my\\directory\\ + favorites = { + "/media/" .. user, + "/mnt/second/videos", + home .. "/Downloads", + home .. "/Torrents/complete", + home .. "/Videos", + home .. "/.config/mpv/playlists", + }, + --list of paths to ignore. the value is anything that returns true for if-statement. + --directory ignore entries must end with a trailing slash, + --but files and all symlinks (even to dirs) must be without slash! + --to help you with the format, simply run "ls -1p <parent folder>" in a terminal, + --and you will see if the file/folder to ignore is listed as "file" or "folder/" (trailing slash). + --you can ignore children without ignoring their parent. + ignorePaths = { + --general linux system paths (some are used by macOS too): + ["/bin/"] = "1", + ["/boot/"] = "1", + ["/cdrom/"] = "1", + ["/dev/"] = "1", + ["/etc/"] = "1", + ["/lib/"] = "1", + ["/lib32/"] = "1", + ["/lib64/"] = "1", + ["/tmp/"] = "1", + ["/srv/"] = "1", + ["/sys/"] = "1", + ["/snap/"] = "1", + ["/root/"] = "1", + ["/sbin/"] = "1", + ["/proc/"] = "1", + ["/opt/"] = "1", + ["/usr/"] = "1", + ["/run/"] = "1", + --useless macOS system paths (some of these standard folders are actually files (symlinks) into /private/ subpaths, hence some repetition): + ["/cores/"] = "1", + ["/etc"] = "1", + ["/installer.failurerequests"] = "1", + ["/net/"] = "1", + ["/private/"] = "1", + ["/tmp"] = "1", + ["/var"] = "1", + }, + --ignore folders and files that match patterns regardless of where they exist on disk. + --make sure you use ^ (start of string) and $ (end of string) to catch the whole str instead of risking partial false positives. + --read about patterns at https://www.lua.org/pil/20.2.html or http://lua-users.org/wiki/PatternsTutorial + ignorePatterns = { + "^initrd%..*/?$", --hide files and folders folders starting with "initrd.<something>" + "^vmlinuz.*/?$", --hide files and folders starting with "vmlinuz<something>" + "^lost%+found/?$", --hide files and folders named "lost+found" + "^%$.*$", --ignore files starting with $ + "^.*%.ico$", + "^.*%.txt$", + "^.*%.ahk$", + "^.*%.reg$", + "^.*%.exe$", + "^.*%.bin$", + "^.*%.mpq$", + "^.*%.inf$", + "^.*%.pdf$", + "^.*%.docx$", + "^.*%.xlsx$", + "^.*%.pptx$", + "^.*%.zip$", + "^.*%.rar$", + "^.*%.tar$", + "^.*%.gz$", + "^.*%.bz2$", + "^.*%.7z$", + "^.*%.pkg$", + "^.*%.deb$", + "^.*%.rpm$", + "^.*%.dll$", + "^.*%.sys$", + "^.*%.cfg$", + "^.*%.ini$", + "^.*%.dat$", + "^.*%.bat$", + "^.*%.cmd$", + "^.*%.js$", + "^.*%.html$", + "^.*%.htm$", + "^.*%.css$", + "^.*%.xml$", + "^.*%.json$", + "^.*%.csv$", + "^.*%.md$", + "^.*%.yml$", + "^.*%.yaml$", + "^.*%.sql$", + "^.*%.py$", + "^.*%.java$", + "^.*%.c$", + "^.*%.cpp$", + "^.*%.h$", + "^.*%.rb$", + "^.*%.pl$", + "^.*%.php$", + "^.*%.asp$", + "^.*%.aspx$", + "^.*%.jsp$", + "^.*%.sh$", + "^.*%.vbs$", + "^.*%.ps1$", + "^.*%.log$", + "^.*%.msg$", + "^.*%.eml$", + "^.*%.ics$", + "^.*%.vcard$", + "^.*%.vcf$", + "^.*%.mdf$", + "^.*%.ldf$", + "^.*%.nfo$", + "^.*%.tmp$", + "^.*%.bak$", + "^.*%.hax$", + }, + + subtitleformats = { + "srt", + "smi", + "ass", + "lrc", + "ssa", + "ttml", + "sbv", + "vtt", + "txt", + }, + + navigator_menu_favkey = "F", --this key will always be bound when the menu is open, and is the key you use to cycle your favorites list! + menu_timeout = false, --menu timeouts and closes itself after navigator_duration seconds, else will be toggled by keybind + navigator_duration = 13, --osd duration before the navigator closes, if timeout is set to true + visible_item_count = 20, --how many menu items to show per screen + + --font size scales by window, if false requires larger font and padding sizes + scale_by_window = true, + --paddings from top left corner + text_padding_x = 10, + text_padding_y = 20, + -- --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 navigator + -- style_ass_tags = "{}", + -- --you can also use the ass tags mentioned above. For example: + -- --selection_prefix="{\\c&HFF00FF&}● " - to add a color for selected file. However, if you + -- --use ass tags you need to set them for both name and selection prefix (see https://github.com/jonniek/mpv-playlistmanager/issues/20) + -- name_prefix = "○ ", + -- selection_prefix = "● ", + + -- For white color: + style_ass_tags = "{\\q2\\an7\\fnUbuntu\\fs8\\b0\\bord1\\c&HFFFFFF&}", + name_prefix = "{\\c&HFFFFFF&}○ ", + + -- For orange selection: + selection_prefix = "{\\c&H0080FF&}● ", +} + +mpopts.read_options(settings) + +--escape a file or directory path for use in shell arguments +function escapepath(dir, escapechar) + return string.gsub(dir, escapechar, "\\" .. escapechar) +end + +local sub_lookup = {} +for _, ext in ipairs(settings.subtitleformats) do + sub_lookup[ext] = true +end + +--ensures directories never accidentally end in "//" due to our added slash +function stripdoubleslash(dir) + if string.sub(dir, -2) == "//" then + return string.sub(dir, 1, -2) --negative 2 removes the last character + else + return dir + end +end + +function os.capture(cmd, raw) + local f = assert(io.popen(cmd, "r")) + local s = assert(f:read("*a")) + f:close() + return string.sub(s, 0, -2) +end + +dir = nil +path = nil +cursor = 0 +length = 0 +--osd handler that displays your navigation and information +function handler() + add_keybinds() + timer:kill() + local ass = assdraw.ass_new() + ass:new_event() + ass:pos(settings.text_padding_x, settings.text_padding_y) + ass:append(settings.style_ass_tags) + + if not path then + if mp.get_property("path") and not settings.forcedefault then + --determine path from currently playing file... + local workingdir = mp.get_property("working-directory") + local playfilename = mp.get_property("filename") --just the filename, without path + local playpath = mp.get_property("path") --can be relative or absolute depending on what args mpv was given + local firstchar = string.sub(playpath, 1, 1) + --first we need to remove the filename (may give us empty path if mpv was started in same dir as file) + path = string.sub(playpath, 1, string.len(playpath) - string.len(playfilename)) + if firstchar ~= "/" and not ON_WINDOWS then --the path of the playing file wasn't absolute, so we need to add mpv's working dir to it + path = workingdir .. "/" .. path + end + --now resolve that path (to resolve things like "/home/anon/Movies/../Movies/foo.mkv") + path = resolvedir(path) + --lastly, check if the folder exists, and if not then fall back to the current mpv working dir + if not isfolder(path) then + if ON_WINDOWS then + path = workingdir .. SEPARATOR_WINDOWS + else + path = workingdir + end + end + else + path = settings.defaultpath + end + dir, length = scandirectory(path) + end + ass:append(path .. "\\N\\N") + local b = cursor - math.floor(settings.visible_item_count / 2) + if b > 0 then + ass:append("...\\N") + end + if b < 0 then + b = 0 + end + for a = b, (b + settings.visible_item_count), 1 do + if a == length then + break + end + local prefix = (a == cursor and settings.selection_prefix or settings.name_prefix) + ass:append(prefix .. dir[a] .. "\\N") + if a == (b + settings.visible_item_count) then + ass:append("...") + end + end + local w, h = mp.get_osd_size() + if settings.scale_by_window then + w, h = 0, 0 + end + mp.set_osd_ass(w, h, ass.text) + if settings.menu_timeout then + timer:resume() + end +end + +function navdown() + if cursor ~= length - 1 then + cursor = cursor + 1 + else + cursor = 0 + end + handler() +end + +function navup() + if cursor ~= 0 then + cursor = cursor - 1 + else + cursor = length - 1 + end + handler() +end + +--moves into selected directory, or appends to playlist incase of file +function childdir() + local item = dir[cursor] + + -- windows only + if ON_WINDOWS then + if WINDOWS_ROOTDIR then + WINDOWS_ROOTDIR = false + end + if item then + local newdir = utils.join_path(path, item):gsub(SEPARATOR, SEPARATOR_WINDOWS) .. SEPARATOR_WINDOWS + local info, error = utils.file_info(newdir) + + if info and info.is_dir then + changepath(newdir) + else + if issubtitle(item) then + loadsubs(utils.join_path(path, item)) + else + mp.commandv("loadfile", utils.join_path(path, item), "append-play") + mp.osd_message("Appended file to playlist: " .. item) + end + handler() + end + end + + return + end + + if item then + if isfolder(utils.join_path(path, item)) then + local newdir = stripdoubleslash(utils.join_path(path, dir[cursor] .. "/")) + changepath(newdir) + else + if issubtitle(item) then + loadsubs(utils.join_path(path, item)) + else + mp.commandv("loadfile", utils.join_path(path, item), "append-play") + mp.osd_message("Appended file to playlist: " .. item) + end + handler() + end + end +end + +function issubtitle(file) + local ext = file:match("^.+%.(.+)$") + return ext and sub_lookup[ext:lower()] +end + +function loadsubs(file) + mp.commandv("sub_add", file) + mp.osd_message("Loaded subtitle: " .. file) +end + +--replace current playlist with directory or file +--if directory, mpv will recursively queue all items found in the directory and its subfolders +function opendir() + local item = dir[cursor] + + if item then + remove_keybinds() + + local filepath = utils.join_path(path, item) + if ON_WINDOWS then + filepath = filepath:gsub(SEPARATOR, SEPARATOR_WINDOWS) + end + + if issubtitle(item) then + return loadsubs(filepath) + end + + mp.commandv("loadfile", filepath, "replace") + end +end + +--changes the directory to the path in argument +function changepath(args) + path = args + if WINDOWS_ROOTDIR then + path = WINDOWS_ROOT_DESC + end + dir, length = scandirectory(path) + cursor = 0 + handler() +end + +--move up to the parent directory +function parentdir() + -- windows only + if ON_WINDOWS then + if path:sub(-1) == SEPARATOR_WINDOWS then + path = path:sub(1, -2) + end + local parent = utils.split_path(path) + if path == parent then + WINDOWS_ROOTDIR = true + end + changepath(parent) + return + end + + --if path doesn't exist or can't be entered, this returns "/" (root of the drive) as the parent + local parent = stripdoubleslash( + os.capture('cd "' .. escapepath(path, '"') .. '" 2>/dev/null && cd .. 2>/dev/null && pwd') .. "/" + ) + + changepath(parent) +end + +--resolves relative paths such as "/home/foo/../foo/Music" (to "/home/foo/Music") if the folder exists! +function resolvedir(dir) + local safedir = escapepath(dir, '"') + + -- windows only + if ON_WINDOWS then + local resolved = stripdoubleslash(os.capture('cd /d "' .. safedir .. '" && cd')) + return resolved .. SEPARATOR_WINDOWS + end + + --if dir doesn't exist or can't be entered, this returns "/" (root of the drive) as the resolved path + local resolved = stripdoubleslash(os.capture('cd "' .. safedir .. '" 2>/dev/null && pwd') .. "/") + return resolved +end + +--true if path exists and is a folder, otherwise false +function isfolder(dir) + -- windows only + if ON_WINDOWS then + local info, error = utils.file_info(dir) + return info and info.is_dir or nil + end + + local lua51returncode, _, lua52returncode = os.execute('test -d "' .. escapepath(dir, '"') .. '"') + return lua51returncode == 0 or lua52returncode == 0 +end + +function scandirectory(searchdir) + local directory = {} + --list all files, using universal utilities and flags available on both Linux and macOS + -- ls: -1 = list one file per line, -p = append "/" indicator to the end of directory names, -v = display in natural order + -- stderr messages are ignored by sending them to /dev/null + -- hidden files ("." prefix) are skipped, since they exist everywhere and never contain media + -- if we cannot list the contents (due to no permissions, etc), this returns an empty list + + -- windows only + if ON_WINDOWS then + -- handle drive letters + if WINDOWS_ROOTDIR then + local popen, err = io.popen("wmic logicaldisk get caption") + local i = 0 + if popen then + for direntry in popen:lines() do + -- only single letter followed by colon (:) are valid + if string.find(direntry, "^%a:") then + direntry = string.sub(direntry, 1, 2) + local matchedignore = false + for k, pattern in pairs(settings.ignorePatterns) do + if direntry:find(pattern) then + matchedignore = true + break --don't waste time scanning further patterns + end + end + if not matchedignore and not settings.ignorePaths[path .. direntry] then + directory[i] = direntry + i = i + 1 + end + end + end + popen:close() + else + mp.msg.error("Could not scan for files :" .. (err or "")) + end + + return directory, i + end + + local i = 0 + local files = utils.readdir(searchdir) + + if not files then + mp.msg.error("Could not scan for files :" .. (err or "")) + return directory, i + end + + for _, direntry in ipairs(files) do + local matchedignore = false + for k, pattern in pairs(settings.ignorePatterns) do + if direntry:find(pattern) then + matchedignore = true + break --don't waste time scanning further patterns + end + end + if not matchedignore and not settings.ignorePaths[path .. direntry] then + directory[i] = direntry + i = i + 1 + end + end + + return directory, i + end + + local popen, err = io.popen('ls -1vp "' .. escapepath(searchdir, '"') .. '" 2>/dev/null') + local i = 0 + if popen then + for direntry in popen:lines() do + local matchedignore = false + for k, pattern in pairs(settings.ignorePatterns) do + if direntry:find(pattern) then + matchedignore = true + break --don't waste time scanning further patterns + end + end + if not matchedignore and not settings.ignorePaths[path .. direntry] then + directory[i] = direntry + i = i + 1 + end + end + popen:close() + else + mp.msg.error("Could not scan for files :" .. (err or "")) + end + return directory, i +end + +favcursor = 1 +function cyclefavorite() + local firstpath = settings.favorites[1] + if not firstpath then + return + end + local favpath = nil + local favlen = 0 + for key, fav in pairs(settings.favorites) do + favlen = favlen + 1 + if key == favcursor then + favpath = fav + end + end + if favpath then + changepath(favpath) + favcursor = favcursor + 1 + else + changepath(firstpath) + favcursor = 2 + end +end + +function add_keybinds() + mp.add_forced_key_binding(settings.key_navdown, "navdown", navdown, "repeatable") + mp.add_forced_key_binding(settings.key_navup, "navup", navup, "repeatable") + mp.add_forced_key_binding(settings.key_navopen, "navopen", opendir) + mp.add_forced_key_binding(settings.key_navforward, "navforward", childdir) + mp.add_forced_key_binding(settings.key_navback, "navback", parentdir) + mp.add_forced_key_binding(settings.key_navfavorites, "navfavorites", cyclefavorite) + mp.add_forced_key_binding(settings.key_navclose, "navclose", remove_keybinds) +end + +function remove_keybinds() + timer:kill() + mp.set_osd_ass(0, 0, "") + if settings.dynamic_binds then + mp.remove_key_binding("navdown") + mp.remove_key_binding("navup") + mp.remove_key_binding("navopen") + mp.remove_key_binding("navforward") + mp.remove_key_binding("navback") + mp.remove_key_binding("navfavorites") + mp.remove_key_binding("navclose") + end +end + +timer = mp.add_periodic_timer(settings.navigator_duration, remove_keybinds) +timer:kill() + +if not settings.dynamic_binds then + add_keybinds() +end + +active = false +function activate() + if settings.menu_timeout then + handler() + else + if active then + remove_keybinds() + active = false + else + handler() + active = true + end + end +end + +mp.add_key_binding(settings.navigator_mainkey, "navigator", activate) diff --git a/mac/.config/mpv/scripts/osc-show-hide.lua b/mac/.config/mpv/scripts/osc-show-hide.lua new file mode 100644 index 0000000..8188e9b --- /dev/null +++ b/mac/.config/mpv/scripts/osc-show-hide.lua @@ -0,0 +1,40 @@ +-- osc-show-hide.lua - show or hide the on-screen controller (a script for mpv player) +-- copyright (c) 2024 Alex Rogers <https://github.com/linguisticmind> and contributors <https://github.com/linguisticmind/mpv-scripts/graphs/contributors> +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License +-- along with this program. If not, see <https://www.gnu.org/licenses/>. + +-- Video tutorial: https://youtu.be/Pp3a5O5OI9U&t=1m06s + +-- version: 0.1.1 + +require("mp.options") +local utils = require("mp.utils") + +local options = { + hidden_mode = "never", -- Accepted values are `'never'` or `'auto'`. +} + +read_options(options, "osc_show_hide") + +local function osc_show_hide() + local visibility = utils.shared_script_property_get("osc-visibility") + mp.commandv( + "script-message", + "osc-visibility", + ((visibility == "auto" or visibility == "never") and "always" or options.hidden_mode), + "no-osd" + ) +end + +mp.add_key_binding("t", "osc-show-hide", osc_show_hide) diff --git a/mac/.config/mpv/scripts/osc.lua b/mac/.config/mpv/scripts/osc.lua new file mode 100644 index 0000000..37791b0 --- /dev/null +++ b/mac/.config/mpv/scripts/osc.lua @@ -0,0 +1,3109 @@ +mp.set_property("osc", "no") +if mp.get_script_name() ~= "osc" then + -- reclaim osc script name after the builtin osc unloads + local script_path = debug.getinfo(1, "S").source:match("^@?(.*[\\/]osc%.lua)$") + if script_path then + mp.add_timeout(0.05, function() + mp.commandv("load-script", script_path) + end) + return + end +end +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 +-- do not touch, change them in osc.conf +local user_opts = { + showwindowed = true, -- show OSC when windowed? + showfullscreen = true, -- show OSC when fullscreen? + idlescreen = true, -- show mpv logo on idle + 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 = true, -- scale the controller with the video? + valign = 0.8, -- vertical alignment, -1 (top) to 1 (bottom) + halign = 0, -- horizontal alignment, -1 (left) to 1 (right) + barmargin = 0, -- vertical margin of top/bottombar + boxalpha = 80, -- alpha of the background box, + -- 0 (opaque) to 255 (fully transparent) + hidetimeout = 500, -- duration in ms until the OSC hides if no + -- mouse movement. enforced non-negative for the + -- user, but internally negative is "always-on". + fadeduration = 200, -- duration of fade out in ms, 0 = no fade + deadzonesize = 0.5, -- size of deadzone + minmousemove = 0, -- 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) + layout = "bottombar", + seekbarstyle = "bar", -- bar, diamond or knob + seekbarhandlesize = 0.6, -- size ratio of the diamond and knob handle + seekrangestyle = "inverted",-- bar, line, slider, inverted or none + seekrangeseparate = true, -- whether the seekranges overlay on the bar-style seekbar + seekrangealpha = 200, -- transparency of seekranges + seekbarkeyframes = true, -- use keyframes when dragging the seekbar + scrollcontrols = true, -- allow scrolling when hovering certain OSC elements + title = "${media-title}", -- string compatible with property-expansion + -- to be shown as OSC title + tooltipborder = 1, -- border of tooltip in bottom/topbar + timetotal = false, -- display total time instead of remaining time? + remaining_playtime = true, -- display the remaining time in playtime or video-time mode + -- playtime takes speed into account, whereas video-time doesn't + timems = false, -- display timecodes with milliseconds? + tcspace = 100, -- timecode spacing (compensate font size estimation) + visibility = "auto", -- only used at init to set visibility_mode(...) + boxmaxchars = 80, -- title crop threshold for box layout + boxvideo = false, -- apply osc_param.video_margins to video + windowcontrols = "auto", -- whether to show window controls + windowcontrols_alignment = "right", -- which side to show window controls on + greenandgrumpy = false, -- disable santa hat + livemarkers = true, -- update seekbar chapter markers on duration change + chapters_osd = true, -- whether to show chapters OSD on next/prev + playlist_osd = true, -- whether to show playlist OSD on next/prev + chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable + unicodeminus = false, -- whether to use the Unicode minus sign character +} + +-- read options from config and command-line +opt.read_options(user_opts, "osc", function(list) update_options(list) end) + +local osc_param = { -- calculated by osc_init() + playresy = 0, -- canvas size Y + playresx = 0, -- canvas size X + display_aspect = 1, + unscaled_y = 0, + areas = {}, + video_margins = { + l = 0, r = 0, t = 0, b = 0, -- left/right/top/bottom + }, +} + +local osc_styles = { + bigButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs50\\fnmpv-osd-symbols}", + smallButtonsL = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs19\\fnmpv-osd-symbols}", + smallButtonsLlabel = "{\\fscx105\\fscy105\\fn" .. mp.get_property("options/osd-font") .. "}", + smallButtonsR = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs30\\fnmpv-osd-symbols}", + topButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\fnmpv-osd-symbols}", + + elementDown = "{\\1c&H999999}", + timecodes = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs20}", + vidtitle = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\q2}", + box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", + + topButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\fnmpv-osd-symbols}", + smallButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs28\\fnmpv-osd-symbols}", + timecodesBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs27}", + timePosBar = "{\\blur0\\bord".. user_opts.tooltipborder .."\\1c&HFFFFFF\\3c&H000000\\fs30}", + vidtitleBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\q2}", + + wcButtons = "{\\1c&HFFFFFF\\fs24\\fnmpv-osd-symbols}", + wcTitle = "{\\1c&HFFFFFF\\fs24\\q2}", + wcBar = "{\\1c&H000000}", +} + +local function create_osd_overlay(...) + if not mp.create_osd_overlay then return end + return mp.create_osd_overlay(...) +end + +-- 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 + tc_ms = user_opts.timems, -- Should the timecodes display their time with milliseconds + mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs + initREQ = false, -- is a re-init request pending? + marginsREQ = false, -- is a margins update 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, + windowcontrols_buttons = false, + dmx_cache = 0, + using_video_margins = false, + border = true, + maximized = false, + osd = create_osd_overlay("ass-events"), + chapter_list = {}, -- sorted by time +} + +local thumbfast = { + width = 0, + height = 0, + disabled = false +} + +local window_control_box_width = 80 +local tick_delay = 0.03 + +local is_december = os.date("*t").month == 12 + +-- +-- Helperfunctions +-- + +function kill_animation() + state.anistart = nil + state.animation = nil + state.anitype = nil +end + +function set_osd(res_x, res_y, text, z) + 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 = z + state.osd:update() +end + +set_osd = state.osd and set_osd or mp.set_osd_ass + +local margins_opts = { + {"l", "video-margin-ratio-left"}, + {"r", "video-margin-ratio-right"}, + {"t", "video-margin-ratio-top"}, + {"b", "video-margin-ratio-bottom"}, +} + +-- 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 + +-- align: -1 .. +1 +-- frame: size of the containing area +-- obj: size of the object that should be positioned inside the area +-- margin: min. distance from object to frame (as long as -1 <= align <= +1) +function get_align(align, frame, obj, margin) + return (frame / 2) + (((frame / 2) - margin - (obj / 2)) * align) +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 + +local c = 0.551915024494 -- circle approximation + +function hexagon_cw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + ass:move_to(x0 + r1, y0) + if x0 ~= x1 then + ass:line_to(x1 - r2, y0) + end + ass:line_to(x1, y0 + r2) + if x0 ~= x1 then + ass:line_to(x1 - r2, y1) + end + ass:line_to(x0 + r1, y1) + ass:line_to(x0, y0 + r1) +end + +function hexagon_ccw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + ass:move_to(x0 + r1, y0) + ass:line_to(x0, y0 + r1) + ass:line_to(x0 + r1, y1) + if x0 ~= x1 then + ass:line_to(x1 - r2, y1) + end + ass:line_to(x1, y0 + r2) + if x0 ~= x1 then + ass:line_to(x1 - r2, y0) + end +end + +function round_rect_cw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + local c1 = c * r1 -- circle approximation + local c2 = c * r2 -- circle approximation + ass:move_to(x0 + r1, y0) + ass:line_to(x1 - r2, y0) -- top line + if r2 > 0 then + ass:bezier_curve(x1 - r2 + c2, y0, x1, y0 + r2 - c2, x1, y0 + r2) -- top right corner + end + ass:line_to(x1, y1 - r2) -- right line + if r2 > 0 then + ass:bezier_curve(x1, y1 - r2 + c2, x1 - r2 + c2, y1, x1 - r2, y1) -- bottom right corner + end + ass:line_to(x0 + r1, y1) -- bottom line + if r1 > 0 then + ass:bezier_curve(x0 + r1 - c1, y1, x0, y1 - r1 + c1, x0, y1 - r1) -- bottom left corner + end + ass:line_to(x0, y0 + r1) -- left line + if r1 > 0 then + ass:bezier_curve(x0, y0 + r1 - c1, x0 + r1 - c1, y0, x0 + r1, y0) -- top left corner + end +end + +function round_rect_ccw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + local c1 = c * r1 -- circle approximation + local c2 = c * r2 -- circle approximation + ass:move_to(x0 + r1, y0) + if r1 > 0 then + ass:bezier_curve(x0 + r1 - c1, y0, x0, y0 + r1 - c1, x0, y0 + r1) -- top left corner + end + ass:line_to(x0, y1 - r1) -- left line + if r1 > 0 then + ass:bezier_curve(x0, y1 - r1 + c1, x0 + r1 - c1, y1, x0 + r1, y1) -- bottom left corner + end + ass:line_to(x1 - r2, y1) -- bottom line + if r2 > 0 then + ass:bezier_curve(x1 - r2 + c2, y1, x1, y1 - r2 + c2, x1, y1 - r2) -- bottom right corner + end + ass:line_to(x1, y0 + r2) -- right line + if r2 > 0 then + ass:bezier_curve(x1, y0 + r2 - c2, x1 - r2 + c2, y0, x1 - r2, y0) -- top right corner + end +end + +function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + hexagon_cw(ass, x0, y0, x1, y1, r1, r2) + else + round_rect_cw(ass, 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 + hexagon_ccw(ass, x0, y0, x1, y1, r1, r2) + else + round_rect_ccw(ass, x0, y0, x1, y1, r1, r2) + end +end + + +-- +-- Tracklist Management +-- + +local nicetypes = {video = "Video", audio = "Audio", sub = "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 = "Available " .. nicetypes[type] .. " Tracks: " + if not tracks_osc or #tracks_osc[type] == 0 then + msg = msg .. "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 <type> by <next> 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 <type>, 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 + else + return val ~= "no" + end +end + +function window_controls_alignment() + return user_opts.windowcontrols_alignment +end + +-- +-- Element Management +-- + +local elements = {} + +function prepare_elements() + + -- remove elements without layout or invisible + 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 r1 = 0 + local r2 = 0 + local slider_lo = element.layout.slider + -- offset between element outline and drag-area + local foV = slider_lo.border + slider_lo.gap + + -- calculate positions of min and max points + if (slider_lo.stype ~= "bar") then + r1 = elem_geo.h / 2 + element.slider.min.ele_pos = elem_geo.h / 2 + element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2) + if (slider_lo.stype == "diamond") then + r2 = (elem_geo.h - 2 * slider_lo.border) / 2 + elseif (slider_lo.stype == "knob") then + r2 = r1 + end + else + element.slider.min.ele_pos = + slider_lo.border + slider_lo.gap + element.slider.max.ele_pos = + elem_geo.w - (slider_lo.border + slider_lo.gap) + end + + 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() + + -- the box + ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, r1, slider_lo.stype == "diamond") + + -- the "hole" + ass_draw_rr_h_ccw(static_ass, slider_lo.border, slider_lo.border, + elem_geo.w - slider_lo.border, elem_geo.h - slider_lo.border, + r2, slider_lo.stype == "diamond") + + -- 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 > 1) then -- draw triangles + + local a = slider_lo.gap / 0.5 --0.866 + + --top + if (slider_lo.nibbles_top) then + static_ass:move_to(s - (a/2), slider_lo.border) + static_ass:line_to(s + (a/2), slider_lo.border) + static_ass:line_to(s, foV) + end + + --bottom + if (slider_lo.nibbles_bottom) then + static_ass:move_to(s - (a/2), + elem_geo.h - slider_lo.border) + static_ass:line_to(s, + elem_geo.h - foV) + static_ass:line_to(s + (a/2), + elem_geo.h - slider_lo.border) + end + + else -- draw 2x1px nibbles + + --top + if (slider_lo.nibbles_top) then + static_ass:rect_cw(s - 1, slider_lo.border, + s + 1, slider_lo.border + slider_lo.gap); + end + + --bottom + if (slider_lo.nibbles_bottom) then + static_ass:rect_cw(s - 1, + elem_geo.h -slider_lo.border -slider_lo.gap, + s + 1, elem_geo.h - slider_lo.border); + 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 +-- + +-- returns nil or a chapter element from the native property chapter-list +function get_chapter(possec) + local cl = state.chapter_list -- sorted, get latest before possec, if any + + for n=#cl,1,-1 do + if possec >= cl[n].time then + return cl[n] + end + end +end + +function render_elements(master_ass) + + -- when the slider is dragged or hovered and we have a target chapter name + -- then we use it instead of the normal title. we calculate it before the + -- render iterations because the title may be rendered before the slider. + state.forced_title = nil + local se, ae = state.slider_element, elements[state.active_element] + if user_opts.chapter_fmt ~= "no" and se and (ae == se or (not ae and mouse_hit(se))) then + local dur = mp.get_property_number("duration", 0) + if dur > 0 then + local possec = get_slider_value(se) * dur / 100 -- of mouse pos + local ch = get_chapter(possec) + if ch and ch.title and ch.title ~= "" then + state.forced_title = string.format(user_opts.chapter_fmt, ch.title) + end + end + end + + 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 foH, xp + local pos = element.slider.posF() + local foV = slider_lo.border + slider_lo.gap + local innerH = elem_geo.h - (2 * foV) + local seekRanges = element.slider.seekRangesF() + local seekRangeLineHeight = innerH / 5 + + if slider_lo.stype ~= "bar" then + foH = elem_geo.h / 2 + else + foH = slider_lo.border + slider_lo.gap + end + + if pos then + xp = get_slider_ele_pos_for(element, pos) + + if slider_lo.stype ~= "bar" then + local r = (user_opts.seekbarhandlesize * innerH) / 2 + ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, + xp + r, foH + r, + r, slider_lo.stype == "diamond") + else + local h = 0 + if seekRanges and user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + h = seekRangeLineHeight + end + elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV - h) + + if seekRanges and not user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + -- Punch holes for the seekRanges to be drawn later + for _,range in pairs(seekRanges) do + if range["start"] < pos then + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = xp + + if pos > range["end"] then + pend = get_slider_ele_pos_for(element, range["end"]) + end + elem_ass:rect_ccw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + end + end + end + end + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw(elem_ass, foH - innerH / 6, foH - innerH / 6, + xp, foH + innerH / 6, + innerH / 6, slider_lo.stype == "diamond", 0) + ass_draw_rr_h_cw(elem_ass, xp, foH - innerH / 15, + elem_geo.w - foH + innerH / 15, foH + innerH / 15, + 0, slider_lo.stype == "diamond", innerH / 15) + for _,range in pairs(seekRanges or {}) do + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = get_slider_ele_pos_for(element, range["end"]) + ass_draw_rr_h_ccw(elem_ass, pstart, foH - innerH / 21, + pend, foH + innerH / 21, + innerH / 21, slider_lo.stype == "diamond") + end + end + end + + if seekRanges then + if slider_lo.rtype ~= "inverted" 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) + end + + 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"]) + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw(elem_ass, pstart, foH - innerH / 21, + pend, foH + innerH / 21, + innerH / 21, slider_lo.stype == "diamond") + elseif slider_lo.rtype == "line" then + if slider_lo.stype == "bar" then + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + else + ass_draw_rr_h_cw(elem_ass, pstart - innerH / 8, foH - innerH / 8, + pend + innerH / 8, foH + innerH / 8, + innerH / 8, slider_lo.stype == "diamond") + end + elseif slider_lo.rtype == "bar" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_cw(elem_ass, pstart - innerH / 2, foV, + pend + innerH / 2, foV + innerH, + innerH / 2, slider_lo.stype == "diamond") + elseif range["end"] >= (pos or 0) then + elem_ass:rect_cw(pstart, foV, pend, elem_geo.h - foV) + else + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + end + elseif slider_lo.rtype == "inverted" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_ccw(elem_ass, pstart, (elem_geo.h / 2) - 1, pend, + (elem_geo.h / 2) + 1, + 1, slider_lo.stype == "diamond") + else + elem_ass:rect_ccw(pstart, (elem_geo.h / 2) - 1, pend, (elem_geo.h / 2) + 1) + end + end + 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 - slider_lo.border + else + ty = element.hitbox.y1 + elem_geo.h/2 + end + + local tx = get_virt_mouse_pos() + local thumb_tx = tx + 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) + + -- thumbnail + if not thumbfast.disabled and thumbfast.width ~= 0 and thumbfast.height ~= 0 then + local osd_w = mp.get_property_number("osd-width") + if osd_w then + local r_w, r_h = get_virt_scale_factor() + + local tooltip_font_size = (user_opts.layout == "box" or user_opts.layout == "slimbox") and 2 or 12 + local thumb_ty = user_opts.layout ~= "topbar" and element.hitbox.y1 - 8 or element.hitbox.y2 + tooltip_font_size + 8 + + local thumb_pad = 2 + local thumb_margin_x = 20 / r_w + local thumb_margin_y = (4 + user_opts.tooltipborder) / r_h + thumb_pad + local thumb_x = math.min(osd_w - thumbfast.width - thumb_margin_x, math.max(thumb_margin_x, thumb_tx / r_w - thumbfast.width / 2)) + local thumb_y = user_opts.layout ~= "topbar" and thumb_ty / r_h - thumbfast.height - tooltip_font_size / r_h - thumb_margin_y or thumb_ty / r_h + thumb_margin_y + + thumb_x = math.floor(thumb_x + 0.5) + thumb_y = math.floor(thumb_y + 0.5) + + elem_ass:new_event() + elem_ass:pos(thumb_x * r_w, thumb_y * r_h) + elem_ass:an(7) + elem_ass:append(osc_styles.timePosBar) + elem_ass:append("{\\1a&H20&}") + elem_ass:draw_start() + elem_ass:rect_cw(-thumb_pad * r_w, -thumb_pad * r_h, (thumbfast.width + thumb_pad) * r_w, (thumbfast.height + thumb_pad) * r_h) + elem_ass:draw_stop() + + mp.commandv("script-message-to", "thumbfast", "thumb", + mp.get_property_number("duration", 0) * (sliderpos / 100), + thumb_x, + thumb_y + ) + end + end + else + if thumbfast.width ~= 0 and thumbfast.height ~= 0 then + mp.commandv("script-message-to", "thumbfast", "clear") + end + 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 + + local maxchars = element.layout.button.maxchars + if not (maxchars == nil) and (#buttontext > maxchars) then + local max_ratio = 1.25 -- up to 25% more chars while shrinking + local limit = math.max(0, math.floor(maxchars * max_ratio) - 3) + if (#buttontext > limit) then + while (#buttontext > limit) do + buttontext = buttontext:gsub(".[\128-\191]*$", "") + end + buttontext = buttontext .. "..." + end + local _, nchars2 = buttontext:gsub(".[\128-\191]*", "") + local stretch = (maxchars/#buttontext)*100 + buttontext = string.format("{\\fscx%f}", + (maxchars/#buttontext)*100) .. buttontext + end + + elem_ass:append(buttontext) + 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 'Empty playlist.' + end + + local message = string.format('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 'No chapters.' + end + + local message = string.format('Chapters [%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('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, + stype = "slider", + 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(topbar) + local wc_geo = { + x = 0, + y = 30 + user_opts.barmargin, + an = 1, + w = osc_param.playresx, + h = 30, + } + + local alignment = window_controls_alignment() + 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 + + if alignment == "left" then + controlbox_left = wc_geo.x + titlebox_left = wc_geo.x + controlbox_w + titlebox_right = wc_geo.w + end + + add_area("window-controls", + get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, + controlbox_w, wc_geo.h)) + + local lo + + -- Background Bar + new_element("wcbar", "box") + lo = add_layout("wcbar") + lo.geometry = wc_geo + lo.layer = 10 + lo.style = osc_styles.wcBar + lo.alpha[1] = user_opts.boxalpha + + local button_y = wc_geo.y - (wc_geo.h / 2) + local first_geo = + {x = controlbox_left + 5, y = button_y, an = 4, w = 25, h = 25} + local second_geo = + {x = controlbox_left + 30, y = button_y, an = 4, w = 25, h = 25} + local third_geo = + {x = controlbox_left + 55, y = button_y, an = 4, w = 25, h = 25} + + -- 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 = alignment == "left" and first_geo or third_geo + lo.style = osc_styles.wcButtons + + -- Minimize: 🗕 + ne = new_element("minimize", "button") + ne.content = "\238\132\146" + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "window-minimized") end + lo = add_layout("minimize") + lo.geometry = alignment == "left" and second_geo or first_geo + lo.style = osc_styles.wcButtons + + -- 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 = alignment == "left" and third_geo or second_geo + lo.style = osc_styles.wcButtons + + -- deadzone below window controls + local sh_area_y0, sh_area_y1 + sh_area_y0 = user_opts.barmargin + sh_area_y1 = wc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), + osc_param.playresy - wc_geo.y, 0, 0) + add_area("showhide_wc", wc_geo.x, sh_area_y0, wc_geo.w, sh_area_y1) + + if topbar then + -- The title is already there as part of the top bar + return + else + -- Apply boxvideo margins to the control bar + osc_param.video_margins.t = wc_geo.h / osc_param.playresy + end + + -- Window Title + ne = new_element("wctitle", "button") + ne.content = function () + local title = mp.command_native({"expand-text", user_opts.title}) + -- escape ASS, and strip newlines and trailing slashes + title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") + return not (title == "") and title or "mpv" + end + local left_pad = 5 + local right_pad = 10 + lo = add_layout("wctitle") + lo.geometry = + { x = titlebox_left + left_pad, y = wc_geo.y - 3, an = 1, + w = titlebox_w, h = wc_geo.h } + lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", + osc_styles.wcTitle, + titlebox_left + left_pad, wc_geo.y - wc_geo.h, + titlebox_right - right_pad , wc_geo.y + wc_geo.h) + + add_area("window-controls-title", + titlebox_left, 0, titlebox_right, wc_geo.h) +end + +-- +-- Layouts +-- + +local layouts = {} + +-- Classic box layout +layouts["box"] = function () + + local osc_geo = { + w = 550, -- width + h = 138, -- height + r = 10, -- corner-radius + p = 15, -- padding + } + + -- make sure the OSC actually fits into the video + if (osc_param.playresx < (osc_geo.w + (2 * osc_geo.p))) then + osc_param.playresy = (osc_geo.w+(2*osc_geo.p))/osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, + osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, + osc_geo.h, 0)) + + -- position offset for contents aligned at the borders of the box + local pos_offsetX = (osc_geo.w - (2*osc_geo.p)) / 2 + local pos_offsetY = (osc_geo.h - (2*osc_geo.p)) / 2 + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), + posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2*user_opts.deadzonesize), + osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + -- fetch values + local osc_w, osc_h, osc_r, osc_p = + osc_geo.w, osc_geo.h, osc_geo.r, osc_geo.p + + local lo + + -- + -- Background box + -- + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = {x = posX, y = posY, an = 5, w = osc_w, h = osc_h} + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = user_opts.boxalpha + lo.box.radius = osc_r + + -- + -- Title row + -- + + local titlerowY = posY - pos_offsetY - 10 + + lo = add_layout("title") + lo.geometry = {x = posX, y = titlerowY, an = 8, w = 496, h = 12} + lo.style = osc_styles.vidtitle + lo.button.maxchars = user_opts.boxmaxchars + + lo = add_layout("pl_prev") + lo.geometry = + {x = (posX - pos_offsetX), y = titlerowY, an = 7, w = 12, h = 12} + lo.style = osc_styles.topButtons + + lo = add_layout("pl_next") + lo.geometry = + {x = (posX + pos_offsetX), y = titlerowY, an = 9, w = 12, h = 12} + lo.style = osc_styles.topButtons + + -- + -- Big buttons + -- + + local bigbtnrowY = posY - pos_offsetY + 35 + local bigbtndist = 60 + + lo = add_layout("playpause") + lo.geometry = + {x = posX, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("skipback") + lo.geometry = + {x = posX - bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("skipfrwd") + lo.geometry = + {x = posX + bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("ch_prev") + lo.geometry = + {x = posX - (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("ch_next") + lo.geometry = + {x = posX + (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("cy_audio") + lo.geometry = + {x = posX - pos_offsetX, y = bigbtnrowY, an = 1, w = 70, h = 18} + lo.style = osc_styles.smallButtonsL + + lo = add_layout("cy_sub") + lo.geometry = + {x = posX - pos_offsetX, y = bigbtnrowY, an = 7, w = 70, h = 18} + lo.style = osc_styles.smallButtonsL + + lo = add_layout("tog_fs") + lo.geometry = + {x = posX+pos_offsetX - 25, y = bigbtnrowY, an = 4, w = 25, h = 25} + lo.style = osc_styles.smallButtonsR + + lo = add_layout("volume") + lo.geometry = + {x = posX+pos_offsetX - (25 * 2) - osc_geo.p, + y = bigbtnrowY, an = 4, w = 25, h = 25} + lo.style = osc_styles.smallButtonsR + + -- + -- Seekbar + -- + + lo = add_layout("seekbar") + lo.geometry = + {x = posX, y = posY+pos_offsetY-22, an = 2, w = pos_offsetX*2, h = 15} + lo.style = osc_styles.timecodes + lo.slider.tooltip_style = osc_styles.vidtitle + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + + -- + -- Timecodes + Cache + -- + + local bottomrowY = posY + pos_offsetY - 5 + + lo = add_layout("tc_left") + lo.geometry = + {x = posX - pos_offsetX, y = bottomrowY, an = 4, w = 110, h = 18} + lo.style = osc_styles.timecodes + + lo = add_layout("tc_right") + lo.geometry = + {x = posX + pos_offsetX, y = bottomrowY, an = 6, w = 110, h = 18} + lo.style = osc_styles.timecodes + + lo = add_layout("cache") + lo.geometry = + {x = posX, y = bottomrowY, an = 5, w = 110, h = 18} + lo.style = osc_styles.timecodes + +end + +-- slim box layout +layouts["slimbox"] = function () + + local osc_geo = { + w = 660, -- width + h = 70, -- height + r = 10, -- corner-radius + } + + -- make sure the OSC actually fits into the video + if (osc_param.playresx < (osc_geo.w)) then + osc_param.playresy = (osc_geo.w)/osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, + osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, + osc_geo.h, 0)) + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), + posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2*user_opts.deadzonesize), + osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo + + local tc_w, ele_h, inner_w = 100, 20, osc_geo.w - 100 + + -- styles + local styles = { + box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", + timecodes = "{\\1c&HFFFFFF\\3c&H000000\\fs20\\bord2\\blur1}", + tooltip = "{\\1c&HFFFFFF\\3c&H000000\\fs12\\bord1\\blur0.5}", + } + + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = 0 + if not (user_opts["seekbarstyle"] == "bar") then + lo.box.radius = osc_geo.r + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + + lo = add_layout("seekbar") + lo.geometry = + {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} + lo.style = osc_styles.timecodes + lo.slider.border = 0 + lo.slider.gap = 1.5 + lo.slider.tooltip_style = styles.tooltip + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + lo.slider.adjust_tooltip = false + + -- + -- Timecodes + -- + + lo = add_layout("tc_left") + lo.geometry = + {x = posX - (inner_w/2) + osc_geo.r, y = posY + 1, + an = 7, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + lo = add_layout("tc_right") + lo.geometry = + {x = posX + (inner_w/2) - osc_geo.r, y = posY + 1, + an = 9, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + -- Cache + + lo = add_layout("cache") + lo.geometry = + {x = posX, y = posY + 1, + an = 8, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + +end + +function bar_layout(direction) + local osc_geo = { + x = -2, + y, + an = (direction < 0) and 7 or 1, + w, + h = 56, + } + + local padX = 9 + local padY = 3 + local buttonW = 27 + local tcW = (state.tc_ms) and 170 or 110 + if user_opts.tcspace >= 50 and user_opts.tcspace <= 200 then + -- adjust our hardcoded font size estimation + tcW = tcW * user_opts.tcspace / 100 + end + + local tsW = 90 + local minW = (buttonW + padX)*5 + (tcW + padX)*4 + (tsW + padX)*2 + + -- Special topbar handling when window controls are present + local padwc_l + local padwc_r + if direction < 0 or not window_controls_enabled() then + padwc_l = 0 + padwc_r = 0 + elseif window_controls_alignment() == "left" then + padwc_l = window_control_box_width + padwc_r = 0 + else + padwc_l = 0 + padwc_r = window_control_box_width + end + + if ((osc_param.display_aspect > 0) and (osc_param.playresx < minW)) then + osc_param.playresy = minW / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + osc_geo.y = direction * (54 + user_opts.barmargin) + osc_geo.w = osc_param.playresx + 4 + if direction < 0 then + osc_geo.y = osc_geo.y + osc_param.playresy + end + + local line1 = osc_geo.y - direction * (9 + padY) + local line2 = osc_geo.y - direction * (36 + padY) + + osc_param.areas = {} + + add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an, + osc_geo.w, osc_geo.h)) + + local sh_area_y0, sh_area_y1 + if direction > 0 then + -- deadzone below OSC + sh_area_y0 = user_opts.barmargin + sh_area_y1 = osc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), + osc_param.playresy - osc_geo.y, 0, 0) + else + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), osc_geo.y, 0, 0) + sh_area_y1 = osc_param.playresy - user_opts.barmargin + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo, geo + + -- Background bar + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = osc_geo + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + + + -- Playlist prev/next + geo = { x = osc_geo.x + padX, y = line1, + an = 4, w = 18, h = 18 - padY } + lo = add_layout("pl_prev") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("pl_next") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + local t_l = geo.x + geo.w + padX + + -- Cache + geo = { x = osc_geo.x + osc_geo.w - padX, y = geo.y, + an = 6, w = 150, h = geo.h } + lo = add_layout("cache") + lo.geometry = geo + lo.style = osc_styles.vidtitleBar + + local t_r = geo.x - geo.w - padX*2 + + -- Title + geo = { x = t_l, y = geo.y, an = 4, + w = t_r - t_l, h = geo.h } + lo = add_layout("title") + lo.geometry = geo + lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", + osc_styles.vidtitleBar, + geo.x, geo.y-geo.h, geo.w, geo.y+geo.h) + + + -- Playback control buttons + geo = { x = osc_geo.x + padX + padwc_l, y = line2, an = 4, + w = buttonW, h = 36 - padY*2} + lo = add_layout("playpause") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("ch_prev") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("ch_next") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Left timecode + geo = { x = geo.x + geo.w + padX + tcW, y = geo.y, an = 6, + w = tcW, h = geo.h } + lo = add_layout("tc_left") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_l = geo.x + padX + + -- Fullscreen button + geo = { x = osc_geo.x + osc_geo.w - buttonW - padX - padwc_r, y = geo.y, an = 4, + w = buttonW, h = geo.h } + lo = add_layout("tog_fs") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Volume + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("volume") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Track selection buttons + geo = { x = geo.x - tsW - padX, y = geo.y, an = geo.an, w = tsW, h = geo.h } + lo = add_layout("cy_sub") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("cy_audio") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + + -- Right timecode + geo = { x = geo.x - padX - tcW - 10, y = geo.y, an = geo.an, + w = tcW, h = geo.h } + lo = add_layout("tc_right") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_r = geo.x - padX + + + -- Seekbar + geo = { x = sb_l, y = geo.y, an = geo.an, + w = math.max(0, sb_r - sb_l), h = geo.h } + new_element("bgbar1", "box") + lo = add_layout("bgbar1") + + lo.geometry = geo + lo.layer = 15 + lo.style = osc_styles.timecodesBar + lo.alpha[1] = + math.min(255, user_opts.boxalpha + (255 - user_opts.boxalpha)*0.8) + if not (user_opts["seekbarstyle"] == "bar") then + lo.box.radius = geo.h / 2 + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + lo = add_layout("seekbar") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + lo.slider.border = 0 + lo.slider.gap = 2 + lo.slider.tooltip_style = osc_styles.timePosBar + lo.slider.tooltip_an = 5 + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + + if direction < 0 then + osc_param.video_margins.b = osc_geo.h / osc_param.playresy + else + osc_param.video_margins.t = osc_geo.h / osc_param.playresy + end +end + +layouts["bottombar"] = function() + bar_layout(-1) +end + +layouts["topbar"] = function() + bar_layout(1) +end + +-- Validate string type user options +function validate_user_opts() + if layouts[user_opts.layout] == nil then + msg.warn("Invalid setting \""..user_opts.layout.."\" for layout") + user_opts.layout = "bottombar" + end + + if user_opts.seekbarstyle ~= "bar" and + user_opts.seekbarstyle ~= "diamond" and + user_opts.seekbarstyle ~= "knob" then + msg.warn("Invalid setting \"" .. user_opts.seekbarstyle + .. "\" for seekbarstyle") + user_opts.seekbarstyle = "bar" + end + + if user_opts.seekrangestyle ~= "bar" and + user_opts.seekrangestyle ~= "line" and + user_opts.seekrangestyle ~= "slider" and + user_opts.seekrangestyle ~= "inverted" and + user_opts.seekrangestyle ~= "none" then + msg.warn("Invalid setting \"" .. user_opts.seekrangestyle + .. "\" for seekrangestyle") + user_opts.seekrangestyle = "inverted" + end + + if user_opts.seekrangestyle == "slider" and + user_opts.seekbarstyle == "bar" then + msg.warn("Using \"slider\" seekrangestyle together with \"bar\" seekbarstyle is not supported") + user_opts.seekrangestyle = "inverted" + end + + 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 + if user_opts.windowcontrols_alignment ~= "right" and + user_opts.windowcontrols_alignment ~= "left" then + msg.warn("windowcontrols_alignment cannot be \"" .. + user_opts.windowcontrols_alignment .. "\". Ignoring.") + user_opts.windowcontrols_alignment = "right" + end +end + +function update_options(list) + validate_user_opts() + request_tick() + visibility_mode(user_opts.visibility, true) + update_duration_watch() + request_init() +end + +local UNICODE_MINUS = string.char(0xe2, 0x88, 0x92) -- UTF-8 for U+2212 MINUS SIGN + +-- 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 + + osc_param.video_margins = {l = 0, r = 0, t = 0, b = 0} + + 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 + + -- title + ne = new_element("title", "button") + + ne.content = function () + local title = state.forced_title or + mp.command_native({"expand-text", user_opts.title}) + -- escape ASS, and strip newlines and trailing slashes + title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") + return not (title == "") and title or "mpv" + end + + ne.eventresponder["mbtn_left_up"] = function () + local title = mp.get_property_osd("media-title") + if (have_pl) then + title = string.format("[%d/%d] %s", countone(pl_pos - 1), + pl_count, title) + end + show_message(title) + end + + ne.eventresponder["mbtn_right_up"] = + function () show_message(mp.get_property_osd("filename")) end + + -- playlist buttons + + -- prev + ne = new_element("pl_prev", "button") + + ne.content = "\238\132\144" + ne.enabled = (pl_pos > 1) or (loop ~= "no") + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("playlist-prev", "weak") + if user_opts.playlist_osd then + show_message(get_playlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_playlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_playlist(), 3) end + + --next + ne = new_element("pl_next", "button") + + ne.content = "\238\132\129" + ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no") + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("playlist-next", "weak") + if user_opts.playlist_osd then + show_message(get_playlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_playlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_playlist(), 3) end + + + -- big buttons + + --playpause + ne = new_element("playpause", "button") + + ne.content = function () + if mp.get_property("pause") == "yes" then + return ("\238\132\129") + else + return ("\238\128\130") + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "pause") end + + --skipback + ne = new_element("skipback", "button") + + ne.softrepeat = true + ne.content = "\238\128\132" + ne.eventresponder["mbtn_left_down"] = + 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.commandv("seek", -30, "relative", "keyframes") end + + --skipfrwd + ne = new_element("skipfrwd", "button") + + ne.softrepeat = true + ne.content = "\238\128\133" + ne.eventresponder["mbtn_left_down"] = + function () mp.commandv("seek", 10, "relative", "keyframes") end + ne.eventresponder["shift+mbtn_left_down"] = + function () mp.commandv("frame-step") end + ne.eventresponder["mbtn_right_down"] = + function () mp.commandv("seek", 60, "relative", "keyframes") end + + --ch_prev + ne = new_element("ch_prev", "button") + + ne.enabled = have_ch + ne.content = "\238\132\132" + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("add", "chapter", -1) + if user_opts.chapters_osd then + show_message(get_chapterlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_chapterlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_chapterlist(), 3) end + + --ch_next + ne = new_element("ch_next", "button") + + ne.enabled = have_ch + ne.content = "\238\132\133" + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("add", "chapter", 1) + if user_opts.chapters_osd then + show_message(get_chapterlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_chapterlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_chapterlist(), 3) end + + -- + update_tracklist() + + --cy_audio + ne = new_element("cy_audio", "button") + + ne.enabled = (#tracks_osc.audio > 0) + ne.content = function () + local aid = "–" + if not (get_track("audio") == 0) then + aid = get_track("audio") + end + return ("\238\132\134" .. osc_styles.smallButtonsLlabel + .. " " .. aid .. "/" .. #tracks_osc.audio) + 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["shift+mbtn_left_down"] = + function () show_message(get_tracklist("audio"), 2) end + + if user_opts.scrollcontrols then + ne.eventresponder["wheel_down_press"] = + function () set_track("audio", 1) end + ne.eventresponder["wheel_up_press"] = + function () set_track("audio", -1) end + end + + --cy_sub + ne = new_element("cy_sub", "button") + + ne.enabled = (#tracks_osc.sub > 0) + ne.content = function () + local sid = "–" + if not (get_track("sub") == 0) then + sid = get_track("sub") + end + return ("\238\132\135" .. osc_styles.smallButtonsLlabel + .. " " .. sid .. "/" .. #tracks_osc.sub) + 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["shift+mbtn_left_down"] = + function () show_message(get_tracklist("sub"), 2) end + + if user_opts.scrollcontrols then + ne.eventresponder["wheel_down_press"] = + function () set_track("sub", 1) end + ne.eventresponder["wheel_up_press"] = + function () set_track("sub", -1) end + end + + --tog_fs + ne = new_element("tog_fs", "button") + ne.content = function () + if (state.fullscreen) then + return ("\238\132\137") + else + return ("\238\132\136") + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "fullscreen") end + + --seekbar + ne = new_element("seekbar", "slider") + + ne.enabled = not (mp.get_property("percent-pos") == nil) + state.slider_element = ne.enabled and ne or nil -- used for forced_title + 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 user_opts.seekrangestyle == "none" 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) + -- 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") end + ne.eventresponder["reset"] = + function (element) element.state.lastseek = nil end + + if user_opts.scrollcontrols then + ne.eventresponder["wheel_up_press"] = + function () mp.commandv("osd-auto", "seek", 10) end + ne.eventresponder["wheel_down_press"] = + function () mp.commandv("osd-auto", "seek", -10) end + end + + + -- tc_left (current pos) + ne = new_element("tc_left", "button") + + ne.content = function () + if (state.tc_ms) then + return (mp.get_property_osd("playback-time/full")) + else + return (mp.get_property_osd("playback-time")) + end + end + ne.eventresponder["mbtn_left_up"] = function () + state.tc_ms = not state.tc_ms + request_init() + end + + -- tc_right (total/remaining time) + ne = new_element("tc_right", "button") + + ne.visible = (mp.get_property_number("duration", 0) > 0) + ne.content = function () + if (state.rightTC_trem) then + local minus = user_opts.unicodeminus and UNICODE_MINUS or "-" + local property = user_opts.remaining_playtime and "playtime-remaining" + or "time-remaining" + if state.tc_ms then + return (minus..mp.get_property_osd(property .. "/full")) + else + return (minus..mp.get_property_osd(property)) + end + else + if state.tc_ms then + return (mp.get_property_osd("duration/full")) + else + return (mp.get_property_osd("duration")) + end + end + end + ne.eventresponder["mbtn_left_up"] = + function () state.rightTC_trem = not state.rightTC_trem end + + -- cache + ne = new_element("cache", "button") + + ne.content = function () + local cache_state = state.cache_state + if not (cache_state and cache_state["seekable-ranges"] and + #cache_state["seekable-ranges"] > 0) then + -- probably not a network stream + return "" + end + local dmx_cache = cache_state and cache_state["cache-duration"] + local thresh = math.min(state.dmx_cache * 0.05, 5) -- 5% or 5s + if dmx_cache and math.abs(dmx_cache - state.dmx_cache) >= thresh then + state.dmx_cache = dmx_cache + else + dmx_cache = state.dmx_cache + end + local min = math.floor(dmx_cache / 60) + local sec = math.floor(dmx_cache % 60) -- don't round e.g. 59.9 to 60 + return "Cache: " .. (min > 0 and + string.format("%sm%02.0fs", min, sec) or + string.format("%3.0fs", sec)) + end + + -- volume + ne = new_element("volume", "button") + + ne.content = function() + local volume = mp.get_property_number("volume", 0) + local mute = mp.get_property_native("mute") + local volicon = {"\238\132\139", "\238\132\140", + "\238\132\141", "\238\132\142"} + if volume == 0 or mute then + return "\238\132\138" + else + return volicon[math.min(4,math.ceil(volume / (100/3)))] + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "mute") end + + if user_opts.scrollcontrols then + ne.eventresponder["wheel_up_press"] = + function () mp.commandv("osd-auto", "add", "volume", 5) end + ne.eventresponder["wheel_down_press"] = + function () mp.commandv("osd-auto", "add", "volume", -5) end + end + + + -- load layout + layouts[user_opts.layout]() + + -- load window controls + if window_controls_enabled() then + window_controls(user_opts.layout == "topbar") + end + + --do something with the elements + prepare_elements() + + update_margins() +end + +function reset_margins() + if state.using_video_margins then + for _, opt in ipairs(margins_opts) do + mp.set_property_number(opt[2], 0.0) + end + state.using_video_margins = false + end +end + +function update_margins() + local margins = osc_param.video_margins + + -- Don't use margins if it's visible only temporarily. + if (not state.osc_visible) or (get_hidetimeout() >= 0) or + (state.fullscreen and not user_opts.showfullscreen) or + (not state.fullscreen and not user_opts.showwindowed) + then + margins = {l = 0, r = 0, t = 0, b = 0} + end + + if user_opts.boxvideo then + -- check whether any margin option has a non-default value + local margins_used = false + + if not state.using_video_margins then + for _, opt in ipairs(margins_opts) do + if mp.get_property_number(opt[2], 0.0) ~= 0.0 then + margins_used = true + end + end + end + + if not margins_used then + for _, opt in ipairs(margins_opts) do + local v = margins[opt[1]] + if (v ~= 0) or state.using_video_margins then + mp.set_property_number(opt[2], v) + state.using_video_margins = true + end + end + end + else + reset_margins() + end + + if mp.del_property then + mp.set_property_native("user-data/osc/margins", margins) + else + utils.shared_script_property_set("osc-margins", + string.format("%f,%f,%f,%f", margins.l, margins.r, margins.t, margins.b)) + end +end + +function shutdown() + reset_margins() + if mp.del_property then + mp.del_property("user-data/osc") + else + utils.shared_script_property_set("osc-margins", nil) + end +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 thumbfast.width ~= 0 and thumbfast.height ~= 0 then + mp.commandv("script-message-to", "thumbfast", "clear") + end + 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 + update_margins() + end + request_tick() +end + +function pause_state(name, enabled) + state.paused = enabled + 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()") + if state.osd then + state.osd.data = "" -- allows set_osd to immediately update on enable + state.osd:remove() + else + set_osd(0, 0, "{}") + end +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.active_element then + -- mouse is held down on some element - keep ticking and ignore initReq + -- till it's released, or else the mouse-up (click) will misbehave or + -- get ignored. that's because osc_init() recreates the osc elements, + -- but mouse handling depends on the elements staying unmodified + -- between mouse-down and mouse-up (using the index active_element). + request_tick() + elseif 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 + kill_animation() + end + else + kill_animation() + 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, "thumbfast-osc-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, "thumbfast-osc-showhide_wc") + end + else + set_virt_mouse_area(0, 0, 0, 0, "thumbfast-osc-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, "thumbfast-osc-input") + end + if state.osc_visible ~= state.input_enabled then + if state.osc_visible then + mp.enable_key_bindings("thumbfast-osc-input") + else + mp.disable_key_bindings("thumbfast-osc-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, "thumbfast-osc-window-controls") + end + if state.osc_visible ~= state.windowcontrols_buttons then + if state.osc_visible then + mp.enable_key_bindings("thumbfast-osc-window-controls") + else + mp.disable_key_bindings("thumbfast-osc-window-controls") + end + state.windowcontrols_buttons = state.osc_visible + 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, 1000) +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 + end + + -- ensure rendering after any (mouse) event - icons could change etc + request_tick() +end + + +local logo_lines = { + -- White border + "{\\c&HE5E5E5&\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 {\\p0}", + -- Purple fill + "{\\c&H682167&\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42{\\p0}", + -- Darker fill + "{\\c&H430142&\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}", + -- White fill + "{\\c&HDDDBDD&\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}", + -- Triangle + "{\\c&H691F69&\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}", +} + +local santa_hat_lines = { + -- Pompoms + "{\\c&HC0C0C0&\\p6}m 500 -323 b 491 -322 481 -318 475 -311 465 -312 456 -319 446 -318 434 -314 427 -304 417 -297 410 -290 404 -282 395 -278 390 -274 387 -267 381 -265 377 -261 379 -254 384 -253 397 -244 409 -232 425 -228 437 -228 446 -218 457 -217 462 -216 466 -213 468 -209 471 -205 477 -203 482 -206 491 -211 499 -217 508 -222 532 -235 556 -249 576 -267 584 -272 584 -284 578 -290 569 -305 550 -312 533 -309 523 -310 515 -316 507 -321 505 -323 503 -323 500 -323{\\p0}", + "{\\c&HE0E0E0&\\p6}m 315 -260 b 286 -258 259 -240 246 -215 235 -210 222 -215 211 -211 204 -188 177 -176 172 -151 170 -139 163 -128 154 -121 143 -103 141 -81 143 -60 139 -46 125 -34 129 -17 132 -1 134 16 142 30 145 56 161 80 181 96 196 114 210 133 231 144 266 153 303 138 328 115 373 79 401 28 423 -24 446 -73 465 -123 483 -174 487 -199 467 -225 442 -227 421 -232 402 -242 384 -254 364 -259 342 -250 322 -260 320 -260 317 -261 315 -260{\\p0}", + -- Main cap + "{\\c&H0000F0&\\p6}m 1151 -523 b 1016 -516 891 -458 769 -406 693 -369 624 -319 561 -262 526 -252 465 -235 479 -187 502 -147 551 -135 588 -111 1115 165 1379 232 1909 761 1926 800 1952 834 1987 858 2020 883 2053 912 2065 952 2088 1000 2146 962 2139 919 2162 836 2156 747 2143 662 2131 615 2116 567 2122 517 2120 410 2090 306 2089 199 2092 147 2071 99 2034 64 1987 5 1928 -41 1869 -86 1777 -157 1712 -256 1629 -337 1578 -389 1521 -436 1461 -476 1407 -509 1343 -507 1284 -515 1240 -519 1195 -521 1151 -523{\\p0}", + -- Cap shadow + "{\\c&H0000AA&\\p6}m 1657 248 b 1658 254 1659 261 1660 267 1669 276 1680 284 1689 293 1695 302 1700 311 1707 320 1716 325 1726 330 1735 335 1744 347 1752 360 1761 371 1753 352 1754 331 1753 311 1751 237 1751 163 1751 90 1752 64 1752 37 1767 14 1778 -3 1785 -24 1786 -45 1786 -60 1786 -77 1774 -87 1760 -96 1750 -78 1751 -65 1748 -37 1750 -8 1750 20 1734 78 1715 134 1699 192 1694 211 1689 231 1676 246 1671 251 1661 255 1657 248 m 1909 541 b 1914 542 1922 549 1917 539 1919 520 1921 502 1919 483 1918 458 1917 433 1915 407 1930 373 1942 338 1947 301 1952 270 1954 238 1951 207 1946 214 1947 229 1945 239 1939 278 1936 318 1924 356 1923 362 1913 382 1912 364 1906 301 1904 237 1891 175 1887 150 1892 126 1892 101 1892 68 1893 35 1888 2 1884 -9 1871 -20 1859 -14 1851 -6 1854 9 1854 20 1855 58 1864 95 1873 132 1883 179 1894 225 1899 273 1908 362 1910 451 1909 541{\\p0}", + -- Brim and tip pompom + "{\\c&HF8F8F8&\\p6}m 626 -191 b 565 -155 486 -196 428 -151 387 -115 327 -101 304 -47 273 2 267 59 249 113 219 157 217 213 215 265 217 309 260 302 285 283 373 264 465 264 555 257 608 252 655 292 709 287 759 294 816 276 863 298 903 340 972 324 1012 367 1061 394 1125 382 1167 424 1213 462 1268 482 1322 506 1385 546 1427 610 1479 662 1510 690 1534 725 1566 752 1611 796 1664 830 1703 880 1740 918 1747 986 1805 1005 1863 991 1897 932 1916 880 1914 823 1945 777 1961 725 1979 673 1957 622 1938 575 1912 534 1862 515 1836 473 1790 417 1755 351 1697 305 1658 266 1633 216 1593 176 1574 138 1539 116 1497 110 1448 101 1402 77 1371 37 1346 -16 1295 15 1254 6 1211 -27 1170 -62 1121 -86 1072 -104 1027 -128 976 -133 914 -130 851 -137 794 -162 740 -181 679 -168 626 -191 m 2051 917 b 1971 932 1929 1017 1919 1091 1912 1149 1923 1214 1970 1254 2000 1279 2027 1314 2066 1325 2139 1338 2212 1295 2254 1238 2281 1203 2287 1158 2282 1116 2292 1061 2273 1006 2229 970 2206 941 2167 938 2138 918{\\p0}", +} + +-- called by mpv on every frame +function tick() + if state.marginsREQ == true then + update_margins() + state.marginsREQ = false + end + + if (not state.enabled) then return end + + if (state.idle) then + + -- render idle message + msg.trace("idle message") + local _, _, display_aspect = mp.get_osd_size() + if display_aspect == 0 then + return + end + local display_h = 360 + local display_w = display_h * display_aspect + -- logo is rendered at 2^(6-1) = 32 times resolution with size 1800x1800 + local icon_x, icon_y = (display_w - 1800 / 32) / 2, 140 + local line_prefix = ("{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}"):format(icon_x, icon_y) + + local ass = assdraw.ass_new() + -- mpv logo + if user_opts.idlescreen then + for i, line in ipairs(logo_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + -- Santa hat + if is_december and user_opts.idlescreen and not user_opts.greenandgrumpy then + for i, line in ipairs(santa_hat_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + if user_opts.idlescreen then + ass:new_event() + ass:pos(display_w / 2, icon_y + 65) + ass:an(8) + ass:append("Drop files or URLs to play here.") + end + set_osd(display_w, display_h, ass.text, -1000) + + if state.showhide_enabled then + mp.disable_key_bindings("thumbfast-osc-showhide") + mp.disable_key_bindings("thumbfast-osc-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 + render_wipe() + end + + state.tick_last_time = mp.get_time() + + if state.anitype ~= nil then + -- state.anistart can be nil - animation should now start, or it can + -- be a timestamp when it started. state.idle has no animation. + if not state.idle and + (not state.anistart or + mp.get_time() < 1 + state.anistart + user_opts.fadeduration/1000) + then + -- animating or starting, or still within 1s past the deadline + request_tick() + else + kill_animation() + end + end +end + +function do_enable_keybindings() + if state.enabled then + if not state.showhide_enabled then + mp.enable_key_bindings("thumbfast-osc-showhide", "allow-vo-dragging+allow-hide-cursor") + mp.enable_key_bindings("thumbfast-osc-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("thumbfast-osc-showhide") + mp.disable_key_bindings("thumbfast-osc-showhide_wc") + end + state.showhide_enabled = false + end +end + +-- duration is observed for the sole purpose of updating chapter markers +-- positions. live streams with chapters are very rare, and the update is also +-- expensive (with request_init), so it's only observed when we have chapters +-- and the user didn't disable the livemarkers option (update_duration_watch). +function on_duration() request_init() end + +local duration_watched = false +function update_duration_watch() + local want_watch = user_opts.livemarkers and + (mp.get_property_number("chapters", 0) or 0) > 0 and + true or false -- ensure it's a boolean + + if (want_watch ~= duration_watched) then + if want_watch then + mp.observe_property("duration", nil, on_duration) + else + mp.unobserve_property(on_duration) + end + duration_watched = want_watch + end +end + +validate_user_opts() +update_duration_watch() + +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.observe_property("chapter-list", "native", function(_, list) + list = list or {} -- safety, shouldn't return nil + table.sort(list, function(a, b) return a.time < b.time end) + state.chapter_list = list + update_duration_watch() + request_init() +end) + +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 + state.marginsREQ = true + request_init_resize() + 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}, +}, "thumbfast-osc-showhide", "force") +mp.set_key_bindings({ + {"mouse_move", function(e) process_event("mouse_move", nil) end}, + {"mouse_leave", mouse_leave}, +}, "thumbfast-osc-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}, + {"shift+mbtn_left", function(e) process_event("shift+mbtn_left", "up") end, + function(e) process_event("shift+mbtn_left", "down") end}, + {"mbtn_right", function(e) process_event("mbtn_right", "up") end, + function(e) process_event("mbtn_right", "down") end}, + -- alias to shift_mbtn_left for single-handed mouse use + {"mbtn_mid", function(e) process_event("shift+mbtn_left", "up") end, + function(e) process_event("shift+mbtn_left", "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"}, + {"shift+mbtn_left_dbl", "ignore"}, + {"mbtn_right_dbl", "ignore"}, +}, "thumbfast-osc-input", "force") +mp.enable_key_bindings("thumbfast-osc-input") + +mp.set_key_bindings({ + {"mbtn_left", function(e) process_event("mbtn_left", "up") end, + function(e) process_event("mbtn_left", "down") end}, +}, "thumbfast-osc-window-controls", "force") +mp.enable_key_bindings("thumbfast-osc-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 == "cycle" then + if not state.enabled then + mode = "auto" + elseif user_opts.visibility ~= "always" then + mode = "always" + else + mode = "never" + end + end + + 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 mp.del_property then + mp.set_property_native("user-data/osc/visibility", mode) + else + utils.shared_script_property_set("osc-visibility", mode) + end + + 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 + -- recalculated on the next render cycle, except in 'never' mode where it + -- will just stay disabled. + mp.disable_key_bindings("thumbfast-osc-input") + mp.disable_key_bindings("thumbfast-osc-window-controls") + state.input_enabled = false + + update_margins() + request_tick() +end + +function idlescreen_visibility(mode, no_osd) + if mode == "cycle" then + if user_opts.idlescreen then + mode = "no" + else + mode = "yes" + end + end + + if mode == "yes" then + user_opts.idlescreen = true + else + user_opts.idlescreen = false + end + + if mp.del_property then + mp.set_property_native("user-data/osc/idlescreen", user_opts.idlescreen) + else + utils.shared_script_property_set("osc-idlescreen", mode) + end + + if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then + mp.osd_message("OSC logo visibility: " .. tostring(mode)) + end + + 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) + +mp.register_script_message("osc-idlescreen", idlescreen_visibility) + +mp.register_script_message("thumbfast-info", function(json) + local data = utils.parse_json(json) + if type(data) ~= "table" or not data.width or not data.height then + msg.error("thumbfast-info: received json didn't produce a table with thumbnail information") + else + thumbfast = data + end +end) + +set_virt_mouse_area(0, 0, 0, 0, "thumbfast-osc-input") +set_virt_mouse_area(0, 0, 0, 0, "thumbfast-osc-window-controls") diff --git a/mac/.config/mpv/scripts/playlist-view.lua b/mac/.config/mpv/scripts/playlist-view.lua new file mode 100644 index 0000000..3000e89 --- /dev/null +++ b/mac/.config/mpv/scripts/playlist-view.lua @@ -0,0 +1,925 @@ +--[[ +mpv-gallery-view | https://github.com/occivink/mpv-gallery-view + +This mpv script generates and displays an overview of the current playlist with thumbnails. + +File placement: scripts/playlist-view.lua +Settings: script-opts/playlist_view.conf +Requires: script-modules/gallery-module.lua +Default keybinding: g script-binding playlist-view-toggle +]] + +local utils = require("mp.utils") +local msg = require("mp.msg") +local options = require("mp.options") + +package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path +require("gallery") + +ON_WINDOWS = (package.config:sub(1, 1) ~= "/") + +-- global variables + +flags = {} +resume = {} +did_pause = false +hash_cache = {} +playlist_pos = 0 + +bindings = {} +bindings_repeat = {} + +compute_geometry = function(ww, wh) end + +ass_changed = false +ass = "" +geometry_changed = false +pending_selection = nil + +thumb_dir = "" + +gallery = gallery_new() +gallery.config.always_show_placeholders = true +gallery.config.accurate = false + +opts = { + thumbs_dir = ON_WINDOWS and "%APPDATA%\\mpv\\gallery-thumbs-dir" or "~/.cache/thumbnails/mpv-gallery/", + generate_thumbnails_with_mpv = ON_WINDOWS, + mkdir_thumbs = true, + + gallery_position = "{ (ww - gw) / 2, (wh - gh) / 2}", + gallery_size = "{ 9 * ww / 10, 9 * wh / 10 }", + min_spacing = "{ 15, 15 }", + thumbnail_size = "(ww * wh <= 1366 * 768) and {192, 108} or {288, 162}", + max_thumbnails = 64, + + take_thumbnail_at = "20%", + + load_file_on_toggle_off = false, + close_on_load_file = true, + pause_on_start = true, + resume_on_stop = "only-if-did-pause", + follow_playlist_position = false, + remember_time_position = true, + + start_on_mpv_startup = false, + start_on_file_end = true, + + show_text = true, + show_title = true, + strip_directory = true, + strip_extension = true, + text_size = 28, + + background_color = "333333", + background_opacity = "33", + normal_border_color = "BBBBBB", + normal_border_size = 1, + selected_border_color = "E5E4E5", + selected_border_size = 6, + highlight_active = true, + active_border_color = "EBC5A7", + active_border_size = 4, + flagged_border_color = "96B58D", + flagged_border_size = 4, + placeholder_color = "222222", + + command_on_open = "", + command_on_close = "", + + flagged_file_path = "./mpv/mpv_gallery_flagged", + + mouse_support = true, + UP = "k", + DOWN = "j", + LEFT = "h", + RIGHT = "l", + PAGE_UP = "ctrl+u", + PAGE_DOWN = "ctrl+d", + FIRST = "0", + LAST = "$", + RANDOM = "r", + ACCEPT = "ENTER", + CANCEL = "ESC", + REMOVE = "BS", + FLAG = "SPACE", +} +function reload_config() + gallery.config.background_color = opts.background_color + gallery.config.background_opacity = opts.background_opacity + gallery.config.max_thumbnails = math.min(opts.max_thumbnails, 64) + gallery.config.placeholder_color = opts.placeholder_color + gallery.config.text_size = opts.text_size + gallery.config.generate_thumbnails_with_mpv = opts.generate_thumbnails_with_mpv + if ON_WINDOWS then + thumbs_dir = string.gsub(opts.thumbs_dir, "^%%APPDATA%%", os.getenv("APPDATA") or "%APPDATA%") + else + thumbs_dir = string.gsub(opts.thumbs_dir, "^~", os.getenv("HOME") or "~") + end + local res = utils.file_info(thumbs_dir) + if not res or not res.is_dir then + if opts.mkdir_thumbs then + local args = ON_WINDOWS and { "mkdir", thumbs_dir } or { "mkdir", "-p", thumbs_dir } + utils.subprocess({ args = args, playback_only = false }) + else + msg.error(string.format('Thumbnail directory "%s" does not exist', thumbs_dir)) + end + end + + compute_geometry = get_geometry_function() + reload_bindings() + if gallery.active then + local ww, wh = mp.get_osd_size() + compute_geometry(ww, wh) + gallery:ass_refresh(true, true, true, true) + end +end +options.read_options(opts, mp.get_script_name(), reload_config) + +local sha256 +--[[ +minified code below is a combination of: +-sha256 implementation from +http://lua-users.org/wiki/SecureHashAlgorithm +-lua implementation of bit32 (used as fallback on lua5.1) from +https://www.snpedia.com/extensions/Scribunto/engines/LuaCommon/lualib/bit32.lua +both are licensed under the MIT below: + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--]] +do + local b, c, d, e, f + if bit32 then + b, c, d, e, f = bit32.band, bit32.rrotate, bit32.bxor, bit32.rshift, bit32.bnot + else + f = function(g) + g = math.floor(tonumber(g)) % 0x100000000 + return (-g - 1) % 0x100000000 + end + local h = { + [0] = { [0] = 0, 0, 0, 0 }, + [1] = { [0] = 0, 1, 0, 1 }, + [2] = { [0] = 0, 0, 2, 2 }, + [3] = { + [0] = 0, + 1, + 2, + 3, + }, + } + local i = { + [0] = { [0] = 0, 1, 2, 3 }, + [1] = { [0] = 1, 0, 3, 2 }, + [2] = { [0] = 2, 3, 0, 1 }, + [3] = { + [0] = 3, + 2, + 1, + 0, + }, + } + local function j(k, l, m, n, o) + for p = 1, m do + l[p] = math.floor(tonumber(l[p])) % 0x100000000 + end + local q = 1 + local r = 0 + for s = 0, 31, 2 do + local t = n + for p = 1, m do + t = o[t][l[p] % 4] + l[p] = math.floor(l[p] / 4) + end + r = r + t * q + q = q * 4 + end + return r + end + b = function(...) + return j("band", { ... }, select("#", ...), 3, h) + end + d = function(...) + return j("bxor", { ... }, select("#", ...), 0, i) + end + e = function(g, u) + g = math.floor(tonumber(g)) % 0x100000000 + u = math.floor(tonumber(u)) + u = math.min(math.max(-32, u), 32) + return math.floor(g / 2 ^ u) % 0x100000000 + end + c = function(g, u) + g = math.floor(tonumber(g)) % 0x100000000 + u = -math.floor(tonumber(u)) % 32 + local g = g * 2 ^ u + return g % 0x100000000 + math.floor(g / 0x100000000) + end + end + local v = { + 0x428a2f98, + 0x71374491, + 0xb5c0fbcf, + 0xe9b5dba5, + 0x3956c25b, + 0x59f111f1, + 0x923f82a4, + 0xab1c5ed5, + 0xd807aa98, + 0x12835b01, + 0x243185be, + 0x550c7dc3, + 0x72be5d74, + 0x80deb1fe, + 0x9bdc06a7, + 0xc19bf174, + 0xe49b69c1, + 0xefbe4786, + 0x0fc19dc6, + 0x240ca1cc, + 0x2de92c6f, + 0x4a7484aa, + 0x5cb0a9dc, + 0x76f988da, + 0x983e5152, + 0xa831c66d, + 0xb00327c8, + 0xbf597fc7, + 0xc6e00bf3, + 0xd5a79147, + 0x06ca6351, + 0x14292967, + 0x27b70a85, + 0x2e1b2138, + 0x4d2c6dfc, + 0x53380d13, + 0x650a7354, + 0x766a0abb, + 0x81c2c92e, + 0x92722c85, + 0xa2bfe8a1, + 0xa81a664b, + 0xc24b8b70, + 0xc76c51a3, + 0xd192e819, + 0xd6990624, + 0xf40e3585, + 0x106aa070, + 0x19a4c116, + 0x1e376c08, + 0x2748774c, + 0x34b0bcb5, + 0x391c0cb3, + 0x4ed8aa4a, + 0x5b9cca4f, + 0x682e6ff3, + 0x748f82ee, + 0x78a5636f, + 0x84c87814, + 0x8cc70208, + 0x90befffa, + 0xa4506ceb, + 0xbef9a3f7, + 0xc67178f2, + } + local function w(n) + return string.gsub(n, ".", function(t) + return string.format("%02x", string.byte(t)) + end) + end + local function x(y, z) + local n = "" + for p = 1, z do + local A = y % 256 + n = string.char(A) .. n + y = (y - A) / 256 + end + return n + end + local function B(n, p) + local z = 0 + for p = p, p + 3 do + z = z * 256 + string.byte(n, p) + end + return z + end + local function C(D, E) + local F = -(E + 1 + 8) % 64 + E = x(8 * E, 8) + D = D .. "\128" .. string.rep("\0", F) .. E + return D + end + local function G(H) + H[1] = 0x6a09e667 + H[2] = 0xbb67ae85 + H[3] = 0x3c6ef372 + H[4] = 0xa54ff53a + H[5] = 0x510e527f + H[6] = 0x9b05688c + H[7] = 0x1f83d9ab + H[8] = 0x5be0cd19 + return H + end + local function I(D, p, H) + local J = {} + for K = 1, 16 do + J[K] = B(D, p + (K - 1) * 4) + end + for K = 17, 64 do + local L = J[K - 15] + local M = d(c(L, 7), c(L, 18), e(L, 3)) + L = J[K - 2] + local N = d(c(L, 17), c(L, 19), e(L, 10)) + J[K] = J[K - 16] + M + J[K - 7] + N + end + local O, s, t, P, Q, R, S, T = H[1], H[2], H[3], H[4], H[5], H[6], H[7], H[8] + for p = 1, 64 do + local M = d(c(O, 2), c(O, 13), c(O, 22)) + local U = d(b(O, s), b(O, t), b(s, t)) + local V = M + U + local N = d(c(Q, 6), c(Q, 11), c(Q, 25)) + local W = d(b(Q, R), b(f(Q), S)) + local X = T + N + W + v[p] + J[p] + T = S + S = R + R = Q + Q = P + X + P = t + t = s + s = O + O = X + V + end + H[1] = b(H[1] + O) + H[2] = b(H[2] + s) + H[3] = b(H[3] + t) + H[4] = b(H[4] + P) + H[5] = b(H[5] + Q) + H[6] = b(H[6] + R) + H[7] = b(H[7] + S) + H[8] = b(H[8] + T) + end + local function Y(H) + return w( + x(H[1], 4) .. x(H[2], 4) .. x(H[3], 4) .. x(H[4], 4) .. x(H[5], 4) .. x(H[6], 4) .. x(H[7], 4) .. x(H[8], 4) + ) + end + local Z = {} + sha256 = function(D) + D = C(D, #D) + local H = G(Z) + for p = 1, #D, 64 do + I(D, p, H) + end + return Y(H) + end +end +-- end of sha code + +gallery.ass_show = function(new_ass) + ass_changed = true + ass = new_ass +end +gallery.item_to_overlay_path = function(index, item) + local filename = item.filename + local filename_hash = hash_cache[filename] + if filename_hash == nil then + filename_hash = string.sub(sha256(normalize_path(filename)), 1, 12) + hash_cache[filename] = filename_hash + end + local thumb_filename = string.format( + "%s_%d_%d_%s", + filename_hash, + gallery.geometry.thumbnail_size[1], + gallery.geometry.thumbnail_size[2], + string.gsub(opts.take_thumbnail_at, "%%", "p") + ) + return utils.join_path(thumbs_dir, thumb_filename) +end +gallery.item_to_thumbnail_params = function(index, item) + return item.filename, opts.take_thumbnail_at +end +function blend_colors(colors) + if #colors == 1 then + return colors[1] + end + local comp1 = 0 + local comp2 = 0 + local comp3 = 0 + for _, val in ipairs(colors) do + comp1 = comp1 + tonumber(string.sub(val, 1, 2), 16) + comp2 = comp2 + tonumber(string.sub(val, 3, 4), 16) + comp3 = comp3 + tonumber(string.sub(val, 5, 6), 16) + end + return string.format("%02x%02x%02x", comp1 / #colors, comp2 / #colors, comp3 / #colors) +end +gallery.item_to_border = function(index, item) + local size = 0 + colors = {} + if flags[item.filename] then + colors[#colors + 1] = opts.flagged_border_color + size = math.max(size, opts.flagged_border_size) + end + if index == gallery.selection then + colors[#colors + 1] = opts.selected_border_color + size = math.max(size, opts.selected_border_size) + end + if opts.highlight_active and index == playlist_pos then + colors[#colors + 1] = opts.active_border_color + size = math.max(size, opts.active_border_size) + end + if #colors == 0 then + return opts.normal_border_size, opts.normal_border_color + else + return size, blend_colors(colors) + end +end +gallery.item_to_text = function(index, item) + if not opts.show_text or index ~= gallery.selection then + return "", false + end + local f + if opts.show_title and item.title then + f = item.title + else + f = item.filename + if opts.strip_directory then + if ON_WINDOWS then + f = string.match(f, "([^\\/]+)$") or f + else + f = string.match(f, "([^/]+)$") or f + end + end + if opts.strip_extension then + f = string.match(f, "(.+)%.[^.]+$") or f + end + end + return f, true +end + +function setup_ui_handlers() + for key, func in pairs(bindings_repeat) do + mp.add_forced_key_binding(key, "playlist-view-" .. key, func, { repeatable = true }) + end + for key, func in pairs(bindings) do + mp.add_forced_key_binding(key, "playlist-view-" .. key, func) + end +end + +function teardown_ui_handlers() + for key, _ in pairs(bindings_repeat) do + mp.remove_key_binding("playlist-view-" .. key) + end + for key, _ in pairs(bindings) do + mp.remove_key_binding("playlist-view-" .. key) + end +end + +function reload_bindings() + if gallery.active then + teardown_ui_handlers() + end + + bindings = {} + bindings_repeat = {} + + local increment_func = function(increment, clamp) + local new = (pending_selection or gallery.selection) + increment + if new <= 0 or new > #gallery.items then + if not clamp then + return + end + new = math.max(1, math.min(new, #gallery.items)) + end + pending_selection = new + end + + bindings[opts.FIRST] = function() + pending_selection = 1 + end + bindings[opts.LAST] = function() + pending_selection = #gallery.items + end + bindings[opts.ACCEPT] = function() + load_selection() + if opts.close_on_load_file then + stop() + end + end + bindings[opts.CANCEL] = function() + stop() + end + bindings[opts.FLAG] = function() + local name = gallery.items[gallery.selection].filename + if flags[name] == nil then + flags[name] = true + else + flags[name] = nil + end + gallery:ass_refresh(true, false, false, false) + end + if opts.mouse_support then + bindings["MBTN_LEFT"] = function() + local index = gallery:index_at(mp.get_mouse_pos()) + if not index then + return + end + if index == gallery.selection then + load_selection() + if opts.close_on_load_file then + stop() + end + else + pending_selection = index + end + end + bindings["WHEEL_UP"] = function() + increment_func(-gallery.geometry.columns, false) + end + bindings["WHEEL_DOWN"] = function() + increment_func(gallery.geometry.columns, false) + end + end + + bindings_repeat[opts.UP] = function() + increment_func(-gallery.geometry.columns, false) + end + bindings_repeat[opts.DOWN] = function() + increment_func(gallery.geometry.columns, false) + end + bindings_repeat[opts.LEFT] = function() + increment_func(-1, false) + end + bindings_repeat[opts.RIGHT] = function() + increment_func(1, false) + end + bindings_repeat[opts.PAGE_UP] = function() + increment_func(-gallery.geometry.columns * gallery.geometry.rows, true) + end + bindings_repeat[opts.PAGE_DOWN] = function() + increment_func(gallery.geometry.columns * gallery.geometry.rows, true) + end + bindings_repeat[opts.RANDOM] = function() + pending_selection = math.random(1, #gallery.items) + end + bindings_repeat[opts.REMOVE] = function() + local s = gallery.selection + mp.commandv("playlist-remove", s - 1) + gallery:set_selection(s + (s == #gallery.items and -1 or 1)) + end + + if gallery.active then + setup_ui_handlers() + end +end + +function get_geometry_function() + local geometry_functions = loadstring(string.format( + [[ + return { + function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th) + return %s + end, + function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th) + return %s + end, + function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th) + return %s + end, + function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th) + return %s + end + }]], + opts.gallery_position, + opts.gallery_size, + opts.min_spacing, + opts.thumbnail_size + ))() + + local names = { "gallery_position", "gallery_size", "min_spacing", "thumbnail_size" } + local order = {} -- the order in which the 4 properties should be computed, based on inter-dependencies + + -- build the dependency matrix + local patterns = { "g[xy]", "g[wh]", "s[wh]", "t[wh]" } + local deps = {} + for i = 1, 4 do + for j = 1, 4 do + local i_depends_on_j = (string.find(opts[names[i]], patterns[j]) ~= nil) + if i == j and i_depends_on_j then + msg.error(names[i] .. " depends on itself") + return + end + deps[i * 4 + j] = i_depends_on_j + end + end + + local has_deps = function(index) + for j = 1, 4 do + if deps[index * 4 + j] then + return true + end + end + return false + end + local num_resolved = 0 + local resolved = { false, false, false, false } + while true do + local resolved_one = false + for i = 1, 4 do + if resolved[i] then + -- nothing to do + elseif not has_deps(i) then + order[#order + 1] = i + -- since i has no deps, anything that depends on it might as well not + for j = 1, 4 do + deps[j * 4 + i] = false + end + resolved[i] = true + resolved_one = true + num_resolved = num_resolved + 1 + end + end + if num_resolved == 4 then + break + elseif not resolved_one then + local str = "" + for index, resolved in ipairs(resolved) do + if not resolved then + str = (str == "" and "" or (str .. ", ")) .. names[index] + end + end + msg.error("Circular dependency between " .. str) + return + end + end + + return function(window_width, window_height) + local new_geom = { + gallery_position = {}, + gallery_size = {}, + min_spacing = {}, + thumbnail_size = {}, + } + for _, index in ipairs(order) do + new_geom[names[index]] = geometry_functions[index]( + window_width, + window_height, + new_geom.gallery_position[1], + new_geom.gallery_position[2], + new_geom.gallery_size[1], + new_geom.gallery_size[2], + new_geom.min_spacing[1], + new_geom.min_spacing[2], + new_geom.thumbnail_size[1], + new_geom.thumbnail_size[2] + ) + -- extrawuerst + if opts.show_text and names[index] == "min_spacing" then + new_geom.min_spacing[2] = math.max(opts.text_size, new_geom.min_spacing[2]) + elseif names[index] == "thumbnail_size" then + new_geom.thumbnail_size[1] = math.floor(new_geom.thumbnail_size[1]) + new_geom.thumbnail_size[2] = math.floor(new_geom.thumbnail_size[2]) + end + end + gallery:set_geometry( + new_geom.gallery_position[1], + new_geom.gallery_position[2], + new_geom.gallery_size[1], + new_geom.gallery_size[2], + new_geom.min_spacing[1], + new_geom.min_spacing[2], + new_geom.thumbnail_size[1], + new_geom.thumbnail_size[2] + ) + end +end + +function normalize_path(path) + if string.find(path, "://") then + return path + end + path = utils.join_path(utils.getcwd(), path) + if ON_WINDOWS then + path = string.gsub(path, "\\", "/") + end + path = string.gsub(path, "/%./", "/") + local n + repeat + path, n = string.gsub(path, "/[^/]*/%.%./", "/", 1) + until n == 0 + return path +end + +function playlist_changed(key, playlist) + if not gallery.active then + return + end + local did_change = function() + if #gallery.items ~= #playlist then + return true + end + for i = 1, #gallery.items do + if gallery.items[i].filename ~= playlist[i].filename then + return true + end + end + return false + end + if not did_change() then + return + end + if #playlist == 0 then + stop() + return + end + local selection_filename = gallery.items[gallery.selection].filename + gallery.items = playlist + local new_selection = math.max(1, math.min(gallery.selection, #gallery.items)) + for i, f in ipairs(gallery.items) do + if selection_filename == f.filename then + new_selection = i + break + end + end + gallery:items_changed(new_selection) +end + +function playlist_pos_changed(_, val) + playlist_pos = val + if opts.highlight_active then + gallery:ass_refresh(true, false, false, false) + end + if opts.follow_playlist_position then + pending_selection = val + end +end + +function idle() + if pending_selection then + gallery:set_selection(pending_selection) + pending_selection = nil + end + if ass_changed or geometry_changed then + local ww, wh = mp.get_osd_size() + if geometry_changed then + geometry_changed = false + compute_geometry(ww, wh) + end + if ass_changed then + ass_changed = false + mp.set_osd_ass(ww, wh, ass) + end + end +end + +function mark_geometry_stale() + geometry_changed = true +end + +function start() + if gallery.active then + return + end + playlist = mp.get_property_native("playlist") + if #playlist == 0 then + return + end + gallery.items = playlist + + local ww, wh = mp.get_osd_size() + compute_geometry(ww, wh) + + playlist_pos = mp.get_property_number("playlist-pos-1") + gallery:set_selection(playlist_pos or 1) + if not gallery:activate() then + return + end + + did_pause = false + if opts.pause_on_start and not mp.get_property_bool("pause", false) then + mp.set_property_bool("pause", true) + did_pause = true + end + if opts.command_on_open ~= "" then + mp.command(opts.command_on_open) + end + mp.observe_property("playlist-pos-1", "native", playlist_pos_changed) + mp.observe_property("playlist", "native", playlist_changed) + mp.observe_property("osd-width", "native", mark_geometry_stale) + mp.observe_property("osd-height", "native", mark_geometry_stale) + mp.register_idle(idle) + idle() + + setup_ui_handlers() +end + +function load_selection() + local sel = mp.get_property_number("playlist-pos-1", -1) + if sel == gallery.selection then + return + end + if opts.remember_time_position then + if sel then + local time = mp.get_property_number("time-pos") + if time and time > 1 then + resume[gallery.items[sel].filename] = time + end + end + mp.set_property("playlist-pos-1", gallery.selection) + local time = resume[gallery.items[gallery.selection].filename] + if not time then + return + end + local func + func = function() + mp.commandv("osd-msg-bar", "seek", time, "absolute") + mp.unregister_event(func) + end + mp.register_event("file-loaded", func) + else + mp.set_property("playlist-pos-1", gallery.selection) + end +end + +function stop() + if not gallery.active then + return + end + if opts.resume_on_stop == "yes" or (opts.resume_on_stop == "only-if-did-pause" and did_pause) then + mp.set_property_bool("pause", false) + end + if opts.command_on_close ~= "" then + mp.command(opts.command_on_close) + end + mp.unobserve_property(playlist_pos_changed) + mp.unobserve_property(playlist_changed) + mp.unobserve_property(mark_geometry_stale) + mp.unregister_idle(idle) + teardown_ui_handlers() + gallery:deactivate() + idle() +end + +function toggle() + if not gallery.active then + start() + else + if opts.load_file_on_toggle_off then + load_selection() + end + stop() + end +end + +mp.register_script_message("thumbnail-generated", function(thumb_path) + gallery:thumbnail_generated(thumb_path) +end) + +mp.register_script_message("thumbnails-generator-broadcast", function(generator_name) + gallery:add_generator(generator_name) +end) + +function write_flag_file() + if next(flags) == nil then + return + end + local out = io.open(opts.flagged_file_path, "w") + for f, _ in pairs(flags) do + out:write(f .. "\n") + end + out:close() +end +mp.register_event("shutdown", write_flag_file) + +reload_config() + +if opts.start_on_file_end then + mp.observe_property("eof-reached", "bool", function(_, val) + if val and mp.get_property_number("playlist-count") > 1 then + start() + end + end) +end + +if opts.start_on_mpv_startup then + local autostart + autostart = function() + if mp.get_property_number("playlist-count") == 0 then + return + end + if mp.get_property_number("osd-width") <= 0 then + return + end + start() + mp.unobserve_property(autostart) + end + mp.observe_property("playlist-count", "number", autostart) + mp.observe_property("osd-width", "number", autostart) +end + +mp.add_key_binding(nil, "playlist-view-open", function() + start() +end) +mp.add_key_binding(nil, "playlist-view-close", stop) +mp.add_key_binding("g", "playlist-view-toggle", toggle) +mp.add_key_binding(nil, "playlist-view-load-selection", load_selection) +mp.add_key_binding(nil, "playlist-view-write-flag-file", write_flag_file) diff --git a/mac/.config/mpv/scripts/playlistmanager.lua b/mac/.config/mpv/scripts/playlistmanager.lua new file mode 100644 index 0000000..a0e20ed --- /dev/null +++ b/mac/.config/mpv/scripts/playlistmanager.lua @@ -0,0 +1,1755 @@ +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, + + -- to bind multiple keys separate them by a space + + -- main key to show playlist + key_showplaylist = "v", + + -- display playlist while key is held down + key_peek_at_playlist = "", + + -- dynamic keys + key_moveup = "k", + key_movedown = "j", + key_movepageup = "ctrl+u", + key_movepagedown = "ctrl+d", + key_movebegin = "g", + key_moveend = "G", + key_selectfile = "space", + key_unselectfile = "ctrl+space", + key_playfile = "ENTER", + key_removefile = "DEL", + key_closeplaylist = "ESC q ctrl+c", + + -- extra functionality keys + key_sortplaylist = "shift+s", + key_shuffleplaylist = "ctrl+s", + key_reverseplaylist = "BS", + key_loadfiles = "alt+r", + key_saveplaylist = "INS", + + --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 = [[ + [ + { + "protocol": { "all": true }, + "rules": [ + { "%%(%x%x)": "hex_to_char" } + ] + } + ] + ]], + + --[=====[ START OF SAMPLE REPLACE - Remove this line to use it + --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 when files are added to playlist + sortplaylist_on_file_add = false, + + --default sorting method, must be one of: "name-asc", "name-desc", "date-asc", "date-desc", "size-asc", "size-desc". + default_sort = "name-asc", + + --"linux | windows | auto" + system = "auto", + + --Use ~ for home directory. Leave as empty to use mpv/playlists + playlist_savepath = "", + + -- constant filename to save playlist as. Note that it will override existing playlist. Leave empty for generated name. + playlist_save_filename = "", + + --save playlist automatically after current file was unloaded + save_playlist_on_file_end = false, + + --show file title every time a new file is loaded + show_title_on_file_load = false, + --show playlist every time a new file is loaded + show_playlist_on_file_load = false, + --close playlist when selecting file to play + close_playlist_on_playfile = false, + + --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, + + --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 = "yt-dlp", + + -- allow playlistmanager to write watch later config when navigating between files + allow_write_watch_later_config = true, + + -- reset cursor navigation when closing or opening playlist + reset_cursor_on_close = true, + reset_cursor_on_open = true, + + --#### 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_url_titles = false, + + --call ffprobe to resolve the titles of local files in the playlist (if they exist in the metadata) + resolve_local_titles = false, + + -- timeout in seconds for url title resolving + resolve_title_timeout = 15, + + -- how many url titles can be resolved at a time. Higher number might lead to stutters. + concurrent_title_resolve_limit = 10, + + --osd timeout on inactivity in seconds, use 0 for no timeout + playlist_display_timeout = 0, + + -- when peeking at playlist, show playlist at the very least for display timeout + peek_respect_display_timeout = false, + + -- the maximum amount of lines playlist will render. -1 will automatically calculate lines. + showamount = -1, + + --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua + --example {\\q2\\an7\\fnUbuntu\\fs10\\b0\\bord1} equals: line-wrap=no, align=top left, 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 + --\\q2 style is recommended since filename wrapping may lead to unexpected rendering + --\\an7 style is recommended to align to top left otherwise, osd-align-x/y is respected + style_ass_tags = "{\\q2\\an7\\fnUbuntu\\fs20\\b0\\bord1\\c&HFFFFFF&}", + + --paddings for left right and top bottom, depends on alignment + text_padding_x = 30, + text_padding_y = 60, + + --screen dim when menu is open 0.0 - 1.0 (0 is no dim, 1 is black) + curtain_opacity = 0.0, + + --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 = "{\\c&HFFFFFF&}○ %name", + hovered_file = "{\\c&H0080FF&}● %name", + selected_file = "{\\c&H00FF00&}➔ %name", + playing_file = "{\\c&HFF00FF&}▷ %name", + playing_hovered_file = "{\\c&H7F40FF&}▶ %name", + playing_selected_file = "{\\c&H00BF7F&}➤ %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, +} +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") + +local alignment_table = { + [1] = { ["x"] = "left", ["y"] = "bottom" }, + [2] = { ["x"] = "center", ["y"] = "bottom" }, + [3] = { ["x"] = "right", ["y"] = "bottom" }, + [4] = { ["x"] = "left", ["y"] = "center" }, + [5] = { ["x"] = "center", ["y"] = "center" }, + [6] = { ["x"] = "right", ["y"] = "center" }, + [7] = { ["x"] = "left", ["y"] = "top" }, + [8] = { ["x"] = "center", ["y"] = "top" }, + [9] = { ["x"] = "right", ["y"] = "top" }, +} + +--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 + +-- auto calculate showamount +if settings.showamount == -1 then + -- same as draw_playlist() height + local h = 720 + + local playlist_h = h + -- both top and bottom with same padding + playlist_h = playlist_h - settings.text_padding_y * 2 + + -- osd-font-size is based on 720p height + -- see https://mpv.io/manual/stable/#options-osd-font-size + -- details in https://mpv.io/manual/stable/#options-sub-font-size + -- draw_playlist() is based on 720p, need some conversion + local fs = mp.get_property_native("osd-font-size") * h / 720 + -- get the ass font size + if settings.style_ass_tags ~= nil then + local ass_fs_tag = settings.style_ass_tags:match("\\fs%d+") + if ass_fs_tag ~= nil then + fs = tonumber(ass_fs_tag:match("%d+")) + end + end + + settings.showamount = math.floor(playlist_h / fs) + + -- exclude the header line + if settings.playlist_header ~= "" then + settings.showamount = settings.showamount - 1 + -- probably some newlines (%N or \N) in the header + for _ in settings.playlist_header:gmatch("%%N") do + settings.showamount = settings.showamount - 1 + end + for _ in settings.playlist_header:gmatch("\\N") do + settings.showamount = settings.showamount - 1 + end + end + + msg.info("auto showamount: " .. settings.showamount) +end + +--global variables +local playlist_overlay = mp.create_osd_overlay("ass-events") +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 title_table = {} +-- table for urls and local file paths that we have requested to be resolved to titles +local requested_titles = {} + +local filetype_lookup = {} + +function refresh_UI() + if not playlist_visible then + return + end + refresh_globals() + if plen == 0 then + return + end + draw_playlist() +end + +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_url_titles then + resolve_titles() + end + + if changelog.resolve_local_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 + + refresh_UI() +end + +update_opts({ filename_replace = true, loadfiles_filetypes = true }) + +----- winapi start ----- +-- in windows system, we can use the sorting function provided by the win32 API +-- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw +local winapisort = nil +if settings.system == "windows" then + -- ffiok is false usually means the mpv builds without luajit + local ffiok, ffi = pcall(require, "ffi") + if ffiok then + ffi.cdef([[ + int MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); + int StrCmpLogicalW(const wchar_t * psz1, const wchar_t * psz2); + ]]) + + local shlwapi = ffi.load("shlwapi.dll") + + function MultiByteToWideChar(MultiByteStr) + local UTF8_CODEPAGE = 65001 + if MultiByteStr then + local utf16_len = ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, nil, 0) + if utf16_len > 0 then + local utf16_str = ffi.new("wchar_t[?]", utf16_len) + if ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then + return utf16_str + end + end + end + return "" + end + + winapisort = function(a, b) + return shlwapi.StrCmpLogicalW(MultiByteToWideChar(a), MultiByteToWideChar(b)) < 0 + end + end +end +----- winapi end ----- + +local sort_modes = { + { + id = "name-asc", + title = "name ascending", + sort_fn = function(a, b, playlist) + if winapisort ~= nil then + return winapisort(playlist[a].string, playlist[b].string) + end + return alphanumsort(playlist[a].string, playlist[b].string) + end, + }, + { + id = "name-desc", + title = "name descending", + sort_fn = function(a, b, playlist) + if winapisort ~= nil then + return winapisort(playlist[b].string, playlist[a].string) + end + return alphanumsort(playlist[b].string, playlist[a].string) + end, + }, + { + id = "date-asc", + title = "date ascending", + sort_fn = function(a, b) + return (get_file_info(a).mtime or 0) < (get_file_info(b).mtime or 0) + end, + }, + { + id = "date-desc", + title = "date descending", + sort_fn = function(a, b) + return (get_file_info(a).mtime or 0) > (get_file_info(b).mtime or 0) + end, + }, + { + id = "size-asc", + title = "size ascending", + sort_fn = function(a, b) + return (get_file_info(a).size or 0) < (get_file_info(b).size or 0) + end, + }, + { + id = "size-desc", + title = "size descending", + sort_fn = function(a, b) + return (get_file_info(a).size or 0) > (get_file_info(b).size or 0) + end, + }, +} + +local sort_mode = 1 +for mode, sort_data in pairs(sort_modes) do + if sort_data.id == settings.default_sort then + sort_mode = mode + end +end + +function is_protocol(path) + return type(path) == "string" and path:match("^%a[%a%d-_]+://") ~= nil +end + +function on_file_loaded() + refresh_globals() + if settings.sync_cursor_on_load then + cursor = pos + end + refresh_UI() -- refresh only after moving cursor + + filename = mp.get_property("filename") + path = mp.get_property("path") + local media_title = mp.get_property("media-title") + if is_protocol(path) and not title_table[path] and path ~= media_title then + title_table[path] = media_title + end + + strippedname = stripfilename(mp.get_property("media-title")) + if settings.show_title_on_file_load then + mp.commandv("show-text", strippedname) + end + if settings.show_playlist_on_file_load then + showplaylist() + end + if settings.set_title_stripped then + mp.set_property("title", settings.title_prefix .. strippedname .. settings.title_suffix) + end +end + +function on_start_file() + refresh_globals() + filename = mp.get_property("filename") + path = mp.get_property("path") + --if not a url then join path with working directory + if not is_protocol(path) then + path = utils.join_path(mp.get_property("working-directory"), path) + directory = utils.split_path(path) + else + directory = nil + end + + if settings.loadfiles_on_start and plen == 1 then + local ext = filename:match("%.([^%.]+)$") + -- a directory or playlist has been loaded, let's not do anything as mpv will expand it into files + if ext and filetype_lookup[ext:lower()] then + msg.info("Loading files from playing files directory") + playlist() + end + end +end + +function on_end_file() + if settings.save_playlist_on_file_end then + save_playlist() + end + strippedname = nil + path = nil + directory = nil + filename = nil +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 + +function replace_table_has_value(value, valid_values) + if value == nil or valid_values == nil then + return false + end + return valid_values["all"] or valid_values[value] +end + +local filename_replace_functions = { + --decode special characters in url + hex_to_char = function(x) + return string.char(tonumber(x, 16)) + 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 replace_table_has_value(ext, v["ext"]) or replace_table_has_value(protocol, v["protocol"]) then + for ruleindex, indexrules in ipairs(v["rules"]) do + for rule, override in pairs(indexrules) do + override = filename_replace_functions[override] or override + 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 the file info of an item +function get_file_info(item) + local path = mp.get_property("playlist/" .. item - 1 .. "/filename") + if is_protocol(path) then + return {} + end + local file_info = utils.file_info(path) + if not file_info then + msg.warn("failed to read file info for", path) + return {} + end + + return file_info +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 is_protocol(name) 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 title_table[name] then + title_table[name] = mtitle + end + title = mtitle + elseif title_table[name] then + title = title_table[name] + end + end + + --if we have media title use a more conservative strip + if title and not notitle and should_use_title then + -- Escape a string for verbatim display on the OSD + -- Ref: https://github.com/mpv-player/mpv/blob/94677723624fb84756e65c8f1377956667244bc9/player/lua/stats.lua#L145 + return stripfilename(title, true):gsub("\\", "\\\239\187\191"):gsub("{", "\\{"):gsub("^ ", "\\h") + 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):gsub("\\", "\\\239\187\191"):gsub("{", "\\{"):gsub("^ ", "\\h") +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") + -- add a blank character at the end of each '\N' to ensure that the height of the empty line is the same as the non empty line + :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() + + local _, _, a = mp.get_osd_size() + local h = 720 + local w = math.ceil(h * a) + + if settings.curtain_opacity ~= nil and settings.curtain_opacity ~= 0 and settings.curtain_opacity <= 1.0 then + -- curtain dim from https://github.com/christoph-heinrich/mpv-quality-menu/blob/501794bfbef468ee6a61e54fc8821fe5cd72c4ed/quality-menu.lua#L699-L707 + local alpha = 255 - math.ceil(255 * settings.curtain_opacity) + ass.text = string.format("{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}", alpha) + ass:draw_start() + ass:rect_cw(0, 0, w, h) + ass:draw_stop() + ass:new_event() + end + + ass:append(settings.style_ass_tags) + + -- align from mpv.conf + local align_x = mp.get_property("osd-align-x") + local align_y = mp.get_property("osd-align-y") + -- align from style_ass_tags + if settings.style_ass_tags ~= nil then + local an = tonumber(settings.style_ass_tags:match("\\an(%d)")) + if an ~= nil and alignment_table[an] ~= nil then + align_x = alignment_table[an]["x"] + align_y = alignment_table[an]["y"] + end + end + -- range of x [0, w-1] + local pos_x + if align_x == "left" then + pos_x = settings.text_padding_x + elseif align_x == "right" then + pos_x = w - 1 - settings.text_padding_x + else + pos_x = math.floor((w - 1) / 2) + end + -- range of y [0, h-1] + local pos_y + if align_y == "top" then + pos_y = settings.text_padding_y + elseif align_y == "bottom" then + pos_y = h - 1 - settings.text_padding_y + else + pos_y = math.floor((h - 1) / 2) + end + ass:pos(pos_x, pos_y) + + if settings.playlist_header ~= "" then + ass:append(parse_header(settings.playlist_header) .. "\\N") + end + + -- (visible index, playlist index) pairs of playlist entries that should be rendered + local visible_indices = {} + + local one_based_cursor = cursor + 1 + table.insert(visible_indices, one_based_cursor) + + local offset = 1 + local visible_indices_length = 1 + while visible_indices_length < settings.showamount and visible_indices_length < plen do + -- add entry for offset steps below the cursor + local below = one_based_cursor + offset + if below <= plen then + table.insert(visible_indices, below) + visible_indices_length = visible_indices_length + 1 + end + + -- add entry for offset steps above the cursor + -- also need to double check that there is still space, this happens if we have even numbered limit + local above = one_based_cursor - offset + if above >= 1 and visible_indices_length < settings.showamount and visible_indices_length < plen then + table.insert(visible_indices, 1, above) + visible_indices_length = visible_indices_length + 1 + end + + offset = offset + 1 + end + + -- both indices are 1 based + for display_index, playlist_index in pairs(visible_indices) do + if display_index == 1 and playlist_index ~= 1 then + ass:append(settings.playlist_sliced_prefix .. "\\N") + elseif display_index == settings.showamount and playlist_index ~= plen then + ass:append(settings.playlist_sliced_suffix) + else + -- parse_filename_by_index expects 0 based index + ass:append(parse_filename_by_index(playlist_index - 1) .. "\\N") + end + end + + playlist_overlay.data = ass.text + playlist_overlay:update() +end + +local peek_display_timer = nil +local peek_button_pressed = false + +function peek_timeout() + peek_display_timer:kill() + if not peek_button_pressed and not playlist_visible then + remove_keybinds() + end +end + +function handle_complex_playlist_toggle(table) + local event = table["event"] + if event == "press" then + msg.error("Complex key event not supported. Falling back to normal playlist display.") + showplaylist() + elseif event == "down" then + showplaylist(1000000) + if settings.peek_respect_display_timeout then + peek_button_pressed = true + peek_display_timer = mp.add_periodic_timer(settings.playlist_display_timeout, peek_timeout) + end + elseif event == "up" then + -- set playlist state to not visible, doesn't actually hide playlist yet + -- this will allow us to check if other functionality has rendered playlist before removing binds + playlist_visible = false + + function remove_keybinds_after_timeout() + -- if playlist is still not visible then lets actually hide it + -- this lets other keys that interupt the peek to render playlist without peek up event closing it + if not playlist_visible then + remove_keybinds() + end + end + + if settings.peek_respect_display_timeout then + peek_button_pressed = false + if not peek_display_timer:is_enabled() then + mp.add_timeout(0.01, remove_keybinds_after_timeout) + end + else + -- use small delay to let dynamic binds run before keys are potentially unbound + mp.add_timeout(0.01, remove_keybinds_after_timeout) + end + end +end + +function toggle_playlist(show_function) + local show = show_function or showplaylist + if playlist_visible then + remove_keybinds() + else + -- toggle always shows without timeout + show(0) + end +end + +function showplaylist(duration) + refresh_globals() + if plen == 0 then + return + end + if not playlist_visible and settings.reset_cursor_on_open then + resetcursor() + end + + playlist_visible = true + add_keybinds() + + draw_playlist() + keybindstimer:kill() + + local dur = tonumber(duration) or settings.playlist_display_timeout + if dur > 0 then + keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) + end +end + +function showplaylist_non_interactive(duration) + refresh_globals() + if plen == 0 then + return + end + if not playlist_visible and settings.reset_cursor_on_open then + resetcursor() + end + playlist_visible = true + draw_playlist() + keybindstimer:kill() + + local dur = tonumber(duration) or settings.playlist_display_timeout + if dur > 0 then + keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) + 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() + selection = nil + 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 + if plen == 1 then + remove_keybinds() + else + showplaylist() + end +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 settings.allow_write_watch_later_config then + if mp.get_property_bool("save-position-on-quit") or force_write then + mp.command("write-watch-later-config") + end + end +end + +function playlist_next() + write_watch_later(true) + mp.commandv("playlist-next", "weak") + if settings.close_playlist_on_playfile then + remove_keybinds() + end + refresh_UI() +end + +function playlist_prev() + write_watch_later(true) + mp.commandv("playlist-prev", "weak") + if settings.close_playlist_on_playfile then + remove_keybinds() + end + refresh_UI() +end + +function playlist_random() + write_watch_later() + refresh_globals() + if plen < 2 then + return + end + math.randomseed(os.time()) + local random = pos + while random == pos do + random = math.random(0, plen - 1) + end + mp.set_property("playlist-pos", random) + if settings.close_playlist_on_playfile then + remove_keybinds() + end +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.close_playlist_on_playfile then + remove_keybinds() + elseif playlist_visible then + showplaylist() + end +end + +function file_filter(filenames) + local files = {} + for i = 1, #filenames do + local file = filenames[i] + local ext = file:match("%.([^%.]+)$") + if ext and filetype_lookup[ext:lower()] then + table.insert(files, file) + end + end + return files +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 dir == "." then + dir = "" + end + if force_dir then + dir = force_dir + end + + local files = file_filter(utils.readdir(dir, "files")) + if winapisort ~= nil then + table.sort(files, winapisort) + else + table.sort(files, alphanumsort) + end + + if files == nil then + msg.verbose("no files in directory") + return + 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 + if file == nil or file[1] == "." then + break + end + 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 + msg.info("Added " .. c + c2 .. " files to playlist") + else + msg.info("No additional files found") + end + cursor = mp.get_property_number("playlist-pos", 1) + else + msg.error("Could not scan for files: " .. (error or "")) + end + refresh_globals() + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + 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 + 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 name = filename + if name == nil then + if settings.playlist_save_filename == nil or settings.playlist_save_filename == "" then + 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 + ) + + name = datestring .. "_playlist-size_" .. length .. ".m3u" + else + name = settings.playlist_save_filename + end + end + + 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 + file:write("#EXTM3U\n") + 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 is_protocol(filename) then + fullpath = utils.join_path(pwd, filename) + end + local title = mp.get_property("playlist/" .. i .. "/title") or title_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 + +-- 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 sort_modes[sort_mode].sort_fn(a, b, playlist) + 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 + + for i = 1, #playlist do + local filename = mp.get_property("playlist/" .. i - 1 .. "/filename") + local ext = filename:match("%.([^%.]+)$") + if not ext or not filetype_lookup[ext:lower()] then + --move the directory to the end of the playlist + mp.commandv("playlist-move", i - 1, #playlist) + 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 + if settings.display_osd_feedback then + mp.osd_message("Playlist sorted with " .. sort_modes[sort_mode].title) + 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() + end + if 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)) + + local playlist = mp.get_property_native("playlist") + for i = 1, #playlist do + local filename = mp.get_property("playlist/" .. i - 1 .. "/filename") + local ext = filename:match("%.([^%.]+)$") + if not ext or not filetype_lookup[ext:lower()] then + --move the directory to the end of the playlist + mp.commandv("playlist-move", i - 1, #playlist) + end + end + + mp.set_property("playlist-pos", 0) + refresh_globals() + if playlist_visible then + showplaylist() + end + if settings.display_osd_feedback then + mp.osd_message("Playlist shuffled") + end +end + +function bind_keys(keys, name, func, opts) + if keys == nil or keys == "" then + mp.add_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_key_binding(key, name .. prefix, func, opts) + i = i + 1 + end +end + +function bind_keys_forced(keys, name, func, opts) + if keys == nil or 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 keys == nil or 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_forced(settings.key_moveup, "moveup", moveup, "repeatable") + bind_keys_forced(settings.key_movedown, "movedown", movedown, "repeatable") + bind_keys_forced(settings.key_movepageup, "movepageup", movepageup, "repeatable") + bind_keys_forced(settings.key_movepagedown, "movepagedown", movepagedown, "repeatable") + bind_keys_forced(settings.key_movebegin, "movebegin", movebegin, "repeatable") + bind_keys_forced(settings.key_moveend, "moveend", moveend, "repeatable") + bind_keys_forced(settings.key_selectfile, "selectfile", selectfile) + bind_keys_forced(settings.key_unselectfile, "unselectfile", unselectfile) + bind_keys_forced(settings.key_playfile, "playfile", playfile) + bind_keys_forced(settings.key_removefile, "removefile", removefile, "repeatable") + bind_keys_forced(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() + playlist_overlay.data = "" + playlist_overlay:update() + 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 + +mp.observe_property("playlist-count", "number", function(_, plcount) + --if we promised to listen and sort on playlist size increase do it + if settings.sortplaylist_on_file_add and (plcount > plen) then + msg.info("Added files will be automatically sorted") + refresh_globals() + sortplaylist() + end + refresh_UI() + resolve_titles() +end) + +url_request_queue = {} +function url_request_queue.push(item) + table.insert(url_request_queue, item) +end +function url_request_queue.pop() + return table.remove(url_request_queue, 1) +end +local url_titles_to_fetch = url_request_queue +local ongoing_url_requests = {} + +function url_fetching_throttler() + if #url_titles_to_fetch == 0 then + url_title_fetch_timer:kill() + end + + local ongoing_url_requests_count = 0 + for _, ongoing in pairs(ongoing_url_requests) do + if ongoing then + ongoing_url_requests_count = ongoing_url_requests_count + 1 + end + end + + -- start resolving some url titles if there is available slots + local amount_to_fetch = math.max(0, settings.concurrent_title_resolve_limit - ongoing_url_requests_count) + for index = 1, amount_to_fetch, 1 do + local file = url_titles_to_fetch.pop() + if file then + ongoing_url_requests[file] = true + resolve_ytdl_title(file) + end + end +end + +url_title_fetch_timer = mp.add_periodic_timer(0.1, url_fetching_throttler) +url_title_fetch_timer:kill() + +local_request_queue = {} +function local_request_queue.push(item) + table.insert(local_request_queue, item) +end +function local_request_queue.pop() + return table.remove(local_request_queue, 1) +end +local local_titles_to_fetch = local_request_queue +local ongoing_local_request = false + +-- this will only allow 1 concurrent local title resolve process +function local_fetching_throttler() + if not ongoing_local_request then + local file = local_titles_to_fetch.pop() + if file then + ongoing_local_request = true + resolve_ffprobe_title(file) + end + end +end + +function resolve_titles() + if settings.prefer_titles == "none" then + return + end + if not settings.resolve_url_titles and not settings.resolve_local_titles then + return + end + + local length = mp.get_property_number("playlist-count", 0) + if length < 2 then + return + end + -- loop all items in playlist because we can't predict how it has changed + local added_urls = false + local added_local = false + for i = 0, length - 1, 1 do + local filename = mp.get_property("playlist/" .. i .. "/filename") + local title = mp.get_property("playlist/" .. i .. "/title") + if i ~= pos and filename and not title and not title_table[filename] and not requested_titles[filename] then + requested_titles[filename] = true + if filename:match("^https?://") then + url_titles_to_fetch.push(filename) + added_urls = true + elseif settings.prefer_titles == "all" then + local_titles_to_fetch.push(filename) + added_local = true + end + end + end + if added_urls then + url_title_fetch_timer:resume() + end + if added_local then + local_fetching_throttler() + end +end + +function resolve_ytdl_title(filename) + local args = { + settings.youtube_dl_executable, + "--no-playlist", + "--flat-playlist", + "-sJ", + "--no-config", + filename, + } + local req = mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + }, function(success, res) + ongoing_url_requests[filename] = false + 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 .. "'") + title_table[filename] = title + refresh_UI() + 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) + ongoing_url_requests[filename] = false + end) +end + +function resolve_ffprobe_title(filename) + local args = { "ffprobe", "-show_format", "-show_entries", "format=tags", "-loglevel", "quiet", filename } + local req = mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + }, function(success, res) + ongoing_local_request = false + local_fetching_throttler() + if res.killed_by_us then + msg.verbose("Request to resolve local title " .. filename .. " timed out") + return + end + if res.status == 0 then + local title = string.match(res.stdout, "title=([^\n\r]+)") + if title then + msg.verbose(filename .. " resolved to '" .. title .. "'") + title_table[filename] = title + refresh_UI() + end + else + msg.error("Failed to resolve local title " .. filename .. " Error: " .. (res.error or "unknown")) + end + 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(showplaylist) + return + end + end + if msg == "show" and value == "playlist-nokeys" then + if value2 ~= "toggle" then + showplaylist_non_interactive(value2) + return + else + toggle_playlist(showplaylist_non_interactive) + 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() + return + end + if msg == "playlist-prev" then + playlist_prev() + return + end + if msg == "playlist-next-random" then + playlist_random() + return + end + if msg == "enable-interactive-save" then + interactive_save = true + end + if msg == "close" then + remove_keybinds() + end +end + +mp.register_script_message("playlistmanager", handlemessage) + +bind_keys(settings.key_sortplaylist, "sortplaylist", function() + sortplaylist() + sort_mode = sort_mode + 1 + if sort_mode > #sort_modes then + sort_mode = 1 + end +end) +bind_keys(settings.key_shuffleplaylist, "shuffleplaylist", shuffleplaylist) +bind_keys(settings.key_reverseplaylist, "reverseplaylist", reverseplaylist) +bind_keys(settings.key_loadfiles, "loadfiles", playlist) +bind_keys(settings.key_saveplaylist, "saveplaylist", activate_playlist_save) +bind_keys(settings.key_showplaylist, "showplaylist", showplaylist) +bind_keys(settings.key_peek_at_playlist, "peek_at_playlist", handle_complex_playlist_toggle, { complex = true }) + +mp.register_event("start-file", on_start_file) +mp.register_event("file-loaded", on_file_loaded) +mp.register_event("end-file", on_end_file) diff --git a/mac/.config/mpv/scripts/reload.lua b/mac/.config/mpv/scripts/reload.lua new file mode 100644 index 0000000..dc449ec --- /dev/null +++ b/mac/.config/mpv/scripts/reload.lua @@ -0,0 +1,19 @@ +--[[ + reload / by sibwaf / https://github.com/sibwaf/mpv-scripts + + Reopens the current playing file and seeks to the same timestamp on a button press ([Shift+R] by default). + Useful for situations when you are watching YouTube/streams and your connection breaks for some reason. + + MIT license - do whatever you want, but I'm not responsible for any possible problems. + Please keep the URL to the original repository. Thanks! +]] + +function reload() + local path = mp.get_property("path") + if path ~= nil then + local time = mp.get_property_number("time-pos") + mp.commandv("loadfile", path, "replace", "start=" .. time) + end +end + +mp.add_key_binding("R", "reload", reload) diff --git a/mac/.config/mpv/scripts/sponsorblock_minimal.lua b/mac/.config/mpv/scripts/sponsorblock_minimal.lua new file mode 100644 index 0000000..c375480 --- /dev/null +++ b/mac/.config/mpv/scripts/sponsorblock_minimal.lua @@ -0,0 +1,141 @@ +-- sponsorblock_minimal.lua +-- +-- This script skips sponsored segments of YouTube videos +-- using data from https://github.com/ajayyy/SponsorBlock + +local opt = require("mp.options") +local utils = require("mp.utils") + +local ON = false +local ranges = nil + +local options = { + server = "https://sponsor.ajay.app/api/skipSegments", + + -- Categories to fetch and skip + categories = '"sponsor"', + + -- Set this to "true" to use sha256HashPrefix instead of videoID + hash = "", +} + +opt.read_options(options) + +function skip_ads(name, pos) + if pos then + for _, i in pairs(ranges) do + v = i.segment[2] + if i.segment[1] <= pos and v > pos then + --this message may sometimes be wrong + --it only seems to be a visual thing though + mp.osd_message( + ("[sponsorblock] skipping forward %ds"):format(math.floor(v - mp.get_property("time-pos"))) + ) + --need to do the +0.01 otherwise mpv will start spamming skip sometimes + --example: https://www.youtube.com/watch?v=4ypMJzeNooo + mp.set_property("time-pos", v + 0.01) + return + end + end + end +end + +function file_loaded() + local video_path = mp.get_property("path", "") + local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" + + local urls = { + "ytdl://youtu%.be/([%w-_]+).*", + "ytdl://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "https?://youtu%.be/([%w-_]+).*", + "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "/watch.*[?&]v=([%w-_]+).*", + "/embed/([%w-_]+).*", + "^ytdl://([%w-_]+)$", + "-([%w-_]+)%.", + } + local youtube_id = nil + local purl = mp.get_property("metadata/by-key/PURL", "") + for i, url in ipairs(urls) do + youtube_id = youtube_id + or string.match(video_path, url) + or string.match(video_referer, url) + or string.match(purl, url) + if youtube_id then + break + end + end + + if not youtube_id or string.len(youtube_id) < 11 then + return + end + youtube_id = string.sub(youtube_id, 1, 11) + + local args = { "curl", "-L", "-s", "-G", "--data-urlencode", ("categories=[%s]"):format(options.categories) } + local url = options.server + if options.hash == "true" then + local sha = mp.command_native({ + name = "subprocess", + capture_stdout = true, + args = { "sha256sum" }, + stdin_data = youtube_id, + }) + url = ("%s/%s"):format(url, string.sub(sha.stdout, 0, 4)) + else + table.insert(args, "--data-urlencode") + table.insert(args, "videoID=" .. youtube_id) + end + table.insert(args, url) + + local sponsors = mp.command_native({ + name = "subprocess", + capture_stdout = true, + playback_only = false, + args = args, + }) + if sponsors.stdout then + local json = utils.parse_json(sponsors.stdout) + if type(json) == "table" then + if options.hash == "true" then + for _, i in pairs(json) do + if i.videoID == youtube_id then + ranges = i.segments + break + end + end + else + ranges = json + end + + if ranges then + ON = true + mp.add_key_binding("", "sponsorblock", toggle) + mp.observe_property("time-pos", "native", skip_ads) + end + end + end +end + +function end_file() + if not ON then + return + end + mp.unobserve_property(skip_ads) + ranges = nil + ON = false +end + +function toggle() + if ON then + mp.unobserve_property(skip_ads) + mp.osd_message("[sponsorblock] off") + ON = false + else + mp.observe_property("time-pos", "native", skip_ads) + mp.osd_message("[sponsorblock] on") + ON = true + end +end + +mp.register_event("file-loaded", file_loaded) +mp.register_event("end-file", end_file) diff --git a/mac/.config/mpv/scripts/subtitle-search.lua b/mac/.config/mpv/scripts/subtitle-search.lua new file mode 100644 index 0000000..53a2ddc --- /dev/null +++ b/mac/.config/mpv/scripts/subtitle-search.lua @@ -0,0 +1,682 @@ +--[[ +Based on sub-search script by kelciour (https://github.com/kelciour/mpv-scripts/blob/master/sub-search.lua) + +Differences from the original script: + +- Searches in a subtitle file active as a primary subtitle instead of attempting to find subtitle files matching video name +- Outputs all search results in OSD list instead of jumping between them with a hotkey (the closest subtitle is selected by default) +- Supports searching unicode text (subtitles should be encoded as utf8, please re-encode your subtitles if you get no results searching for unicode text) +- Embedded console replaced with more recent variant from mpv sources (to support unicode input) +- Takes into account current `sub-delay` value +- Can search in embedded subtitles (requires ffmpeg to be installed to extract subtitles from video files) +- Can search subtitles for youtube videos (requires ffmpeg to be installed to fetch remote subtitles) +- Supports `.srt`, `.vtt` and `.sub` (microdvd) subtitle formats +- Can use special phrase "*" to show all subtitle lines +- Use `ctrl+shift+f` shortcut to show all subtitle lines simultaneously and dynamically highlight the current line +- Press `Ctrl+Shift+Enter` in result list to adjust `sub-delay` so that selected subtitle line is displayed at the current position + +Requires `script-modules/utf8` repository, `script-modules/scroll-list.lua`, `script-modules/sha1.lua`, `script-modules/utf8_data.lua` and `script-modules/input-console.lua` to work. + +You can clone `script-modules/utf8` repository with the following command (assuming you are in mpv config directory): `git clone git@github.com:Stepets/utf8.lua.git script-modules/utf8` + +Usage: + Press Ctrl + F, print something and press Enter. +Example: + 'You are playing Empire Strikes Back and press Ctrl+F, type "I am you father" + Enter + and voilá, the scene pops up.' +--]] + + +package.path = package.path .. ";" .. mp.command_native({ "expand-path", "~~/script-modules/?.lua" }) + +local mp = require("mp") +local utils = require("mp.utils") +local msg = require("mp.msg") +local input_console = require("input-console") +local result_list = require("scroll-list") +local utf8 = require("utf8/init") +local utf8_data = require("utf8_data") +local sha1 = require("sha1") + +utf8.config = { + conversion = { + uc_lc = utf8_data.utf8_uc_lc, + lc_uc = utf8_data.utf8_lc_uc + }, +} + +utf8:init() + +table.insert(result_list.keybinds, { + "ENTER", "jump_to_result", function() + local selected_index = result_list.selected + if selected_index == nil then + return + end + + local selected = result_list.list[selected_index] + mp.commandv("seek", selected.time, "absolute+exact") + end, {} +}) +table.insert(result_list.keybinds, { + "Ctrl+Shift+ENTER", "sync_to_result", function() + local selected_index = result_list.selected + if selected_index == nil then + return + end + + local selected = result_list.list[selected_index] + local old_delay = mp.get_property_native("sub-delay") + local delay = -(selected.original_time - mp.get_property_native("time-pos")) + mp.set_property_native("sub-delay", delay) + end, {} +}) + +function sub_time_to_seconds(time, sep) + if time:match("%d%d:%d%d" .. sep .. "%d%d%d") then + time = "00:" .. time + end + + local major, minor = time:match("(%d%d:%d%d:%d%d)" .. sep .. "(%d%d%d)") + local hours, mins, secs = major:match("(%d%d):(%d%d):(%d%d)") + return hours * 3600 + mins * 60 + secs + minor / 1000 +end + +local subs_cache = {} + +function open_file(path) + local f, err = io.open(path, "r") + if f and err == nil then + return f + end + + return nil +end + +function is_supported_network_protocol(url) + local protocols = { "http", "https" } + + for _, protocol in pairs(protocols) do + if url:sub(1, #protocol + 3) == protocol .. "://" then + return true + end + end + + return false +end + +function get_sub_filename_async(track_name, on_done) + local active_track = mp.get_property_native("current-tracks/" .. track_name) + if active_track == nil then + on_done(nil) + return + end + + local is_external = active_track.external + local external_filename = active_track["external-filename"] + + -- youtube subtitles specified with edl format + if is_external and external_filename and external_filename:sub(1, 6) == "edl://" then + download_subtitle_async(external_filename:match("https://.*"), on_done) + return + end + + if is_external and external_filename and is_supported_network_protocol(external_filename) then + download_subtitle_async(external_filename, on_done) + return + end + + if is_external and external_filename then + on_done(external_filename) + return + end + + if is_external == false then + extract_subtitle_track_async(active_track, on_done) + return + end + + on_done(nil) +end + +function get_path_to_extract_sub(uniq_sub_id) + local sub_filename = sha1.hex(uniq_sub_id) + return utils.join_path(get_temp_dir(), "mpv-subtitle-search-extracted-" .. sub_filename .. ".srt") +end + +function download_subtitle_async(url, on_done) + local sub_path = get_path_to_extract_sub(mp.get_property_native("path") .. "#" .. url) + + if subs_cache[sub_path] then + on_done(sub_path) + return + end + + local extract_overlay = mp.create_osd_overlay("ass-events") + extract_overlay.data = "{\\a3\\fs20}Fetching remote subtitles, wait..." + extract_overlay:update() + + mp.command_native_async({ + name = "subprocess", + capture_stdout = true, + args = { "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", url, "-vn", "-an", "-c:s", "srt", sub_path } + }, function(ok) + if not ok then + extract_overlay.data = "{\\a3\\fs20\\c&HFF&}Extraction failed" + extract_overlay:update() + + mp.add_timeout(2, function() + extract_overlay:remove() + end) + + on_done(nil) + else + extract_overlay:remove() + + on_done(sub_path) + end + end) +end + +function extract_subtitle_track_async(track, on_done) + if track.external then + on_done(nil) + return + end + + local video_file = mp.get_property_native("path") + local working_dir = mp.get_property_native("working-directory") + local full_path = utils.join_path(working_dir, video_file) + + local track_index = track["ff-index"] + local sub_path = get_path_to_extract_sub(full_path .. "#" .. track_index) + + -- check if file already exists + if open_file(sub_path) then + msg.info("Reusing extracted subtitle track from " .. sub_path) + + on_done(sub_path) + return + end + + msg.info("Extracting embedded subtitle track to " .. sub_path) + + local extract_overlay = mp.create_osd_overlay("ass-events") + extract_overlay.data = "{\\a3\\fs20}Extracting embedded subtitles, wait..." + extract_overlay:update() + + mp.command_native_async({ + name = "subprocess", + capture_stdout = true, + args = { "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", full_path, "-map", "0:" .. track_index, "-vn", "-an", "-c:s", "srt", sub_path } + }, function(ok) + if not ok then + extract_overlay.data = "{\\a3\\fs20\\c&HFF&}Extraction failed" + extract_overlay:update() + + mp.add_timeout(2, function() + extract_overlay:remove() + end) + + on_done(nil) + else + extract_overlay:remove() + + on_done(sub_path) + end + end) +end + +function get_temp_dir() + local temp_dir = os.getenv("TMPDIR") + if temp_dir == nil then + temp_dir = os.getenv("TEMP") + end + + if temp_dir == nil then + temp_dir = os.getenv("TMP") + end + + if temp_dir == nil then + temp_dir = "/tmp" + end + + return temp_dir +end + +function get_lines(input) + local lines = {} + + local tail = 1 + for head = 1, #input do + local ch = input:sub(head, head) + if ch == "\n" then + table.insert(lines, input:sub(tail, head - 1)) + tail = head + 1 + elseif head == #input then + table.insert(lines, input:sub(tail, head)) + end + end + + return lines +end + +function trim(s) + return s:gsub("^%s*(.-)%s*$", "%1") +end + +function parse_vtt_sub(data) + local result = {} + local state = "header" + + local cur_line = {} + for _, line in ipairs(get_lines(data)) do + line = trim(line) + if state == "header" then + if line == "" then + state = "body" + end + elseif state == "body" then + if line == "" then + state = "header" + elseif line:match("^NOTE") or line:match("^STYLE") then + state = "comment" + else + local time_text = line:match("^(%d%d:%d%d:%d%d%.%d%d%d)") or line:match("^(%d%d:%d%d%.%d%d%d)") + if time_text then + cur_line.time = sub_time_to_seconds(time_text, ".") + state = "waiting_text" + else + state = "body" + end + end + elseif state == "comment" then + if #line == 0 then + state = "body" + end + elseif state == "waiting_text" then + if #line == 0 or line == nil then + if cur_line.text ~= nil then + table.insert(result, cur_line) + end + + cur_line = {} + state = "body" + else + line = remove_tags(line) + if cur_line.text then + cur_line.text = cur_line.text .. "\n" .. line + else + cur_line.text = line + end + end + end + end + + return result +end + +function remove_tags(text) + function remove_tag(tag_to_remove) + return string.gsub(text, "</?" .. tag_to_remove .. ">", "") + end + + text = remove_tag("b") + text = remove_tag("i") + text = remove_tag("u") + text = remove_tag("ruby") + text = remove_tag("rt") + + -- remove class tag + text = remove_tag("c") + text = string.gsub(text, "<c.[^>]*>", "") + + -- remove voice tag + text = remove_tag("v") + text = string.gsub(text, "<v [^>]*>", "") + + -- remove karaoke karaoke tags + text = string.gsub(text, "</?%d%d:%d%d.%d%d%d>", "") + text = string.gsub(text, "</?%d%d:%d%d:%d%d.%d%d%d>", "") + + -- remove font tag + text = string.gsub(text, '<font color="#?[%d%a]+">', "") + text = string.gsub(text, '</font>', "") + + return text +end + + +-- detects only most common encodings +function get_encoding_from_bom(data) + -- utf8 + local bom = data:sub(1, 3) + if bom == "\xEF\xBB\xBF" then + return "utf-8" + end + + -- utf16 + bom = data:sub(1, 2) + if bom == "\xFF\xFE" or bom == "\xFE\xFF" then + return "utf-16" + end + + -- utf32 + bom = data:sub(1, 4) + if bom == "\xFF\xFE\x00\x00" or bom == "\x00\x00\xFE\xFF" then + return "utf-32" + end + + return nil +end + +function is_microdvd_sub(data) + return data:match("{%d+}{%d+}") +end + +function parse_microdvd_sub(data) + local result = {} + local lines = get_lines(data) + + -- if the first line contains only number, it's a subtitle fps + local subtitle_fps = tonumber(lines[1]) + if subtitle_fps == nil or subtitle_fps == 0 then + subtitle_fps = mp.get_property_native("container-fps") + if subtitle_fps == nil or subtitle_fps == 0 then + subtitle_fps = 24 + end + end + + msg.info("Using " .. subtitle_fps .. "fps for microdvd subtitle") + + for _, line in ipairs(lines) do + local time_text = line:match("^{(%d+)}{(%d+)}") + if time_text then + local start_frame = tonumber(time_text:match("^(%d+)")) + + local text = line:match("^{%d+}{%d+}(.*)") + text = text:gsub("|", " ") + if text then + table.insert(result, { + time = frame_to_secs(start_frame, subtitle_fps), + text = text + }) + end + end + end + + return result +end + +function frame_to_secs(frame, subtitle_fps) + return frame / subtitle_fps +end + +function parse_sub(data) + bom_encoding = get_encoding_from_bom(data) + if bom_encoding ~= nil then + if bom_encoding == "utf-8" then + data = data:sub(3) + else + local error_overlay = mp.create_osd_overlay("ass-events") + error_overlay.data = "{\\a3\\fs20\\c&HFF&}Unsupported subtitle encoding: " .. bom_encoding .. ", please re-encode subtitle file to utf-8 to search" + error_overlay:update() + + msg.error("Unsupported subtitle encoding: " .. bom_encoding .. ", please re-encode subtitle file to utf-8 to search") + + mp.add_timeout(10, function() + error_overlay:remove() + end) + + return {} + end + end + + data = string.gsub(data, "\r\n", "\n") + + if data:sub(1, 6) == "WEBVTT" then + return parse_vtt_sub(data) + end + + if is_microdvd_sub(data) then + return parse_microdvd_sub(data) + end + + local result = {} + local state = "waiting_index" + local cur_line = {} + for _, line in ipairs(get_lines(data)) do + line = trim(line) + if state == "waiting_index" then + if cur_line.text then + table.insert(result, cur_line) + cur_line = {} + end + + if line:match("^%d+$") then + state = "waiting_time" + end + elseif state == "waiting_time" then + local time_text = line:match("^(%d%d:%d%d:%d%d,%d%d%d) ") + if time_text then + cur_line.time = sub_time_to_seconds(time_text, ",") + state = "waiting_text" + else + state = "waiting_index" + end + elseif state == "waiting_text" then + line = remove_tags(line) + if #line == 0 then + if cur_line.text then + table.insert(result, cur_line) + end + cur_line = {} + state = "waiting_index" + elseif cur_line.text then + cur_line.text = cur_line.text .. " " .. line + else + cur_line.text = line + end + end + end + + if cur_line.text then + table.insert(result, cur_line) + end + + return result +end + +function load_sub(path, prefix) + if not path then + return nil + end + + local cached = subs_cache[path] + if cached then + return cached + end + + local f = open_file(path) + if not f then + return nil + end + + local data = f:read("*all") + f:close() + + local sub = { + prefix = prefix, + lines = parse_sub(data) + } + subs_cache[path] = sub + return sub +end + +function make_nocase_pattern(s) + local result = "" + for _, code in utf8.codes(s) do + local c = utf8.char(code) + result = result .. string.format("[%s%s]", utf8.lower(c), utf8.upper(c)) + end + return result +end + +-- highlight found text with colored text in ass syntax +function highlight_match(text, match_text, style_reset) + local match_start, match_end = utf8.find(utf8.lower(text), utf8.lower(match_text)) + if match_start == nil then + return text + end + + local before = result_list.ass_escape(utf8.sub(text, 1, match_start - 1)) + local match = result_list.ass_escape(utf8.sub(text, match_start, match_end)) + local after = result_list.ass_escape(utf8.sub(text, match_end + 1)) + + if style_reset == "" then + style_reset = "{\\c&HFFFFFF&}" + end + + return before .. "{\\c&HFF00&}" .. match .. style_reset .. after +end + +function adjust_sub_time(time) + local delay = mp.get_property_native("sub-delay") + if delay == nil then + return time + end + return time + delay +end + +function divmod (a, b) + return math.floor(a / b), a % b +end + +function format_time(time) + decimals = 3 + sep = "." + local s = time + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + local second_format = string.format("%%0%d.%df", 2 + (decimals > 0 and decimals + 1 or 0), decimals) + + return string.format("%02d" .. sep .. "%02d" .. sep .. second_format, h, m, s) +end + +function get_subs_to_search_in_async(on_done) + local result = {} + + get_sub_filename_async("sub", function(primary_filename) + local sub = load_sub(primary_filename, "P") + if sub then + table.insert(result, sub) + end + + get_sub_filename_async("sub2", function(secondary_filename) + sub = load_sub(secondary_filename, "S") + if sub then + table.insert(result, sub) + end + + on_done(result) + end) + end) +end + +function update_search_results_async(query, live) + get_subs_to_search_in_async(function(subs) + if #subs == 0 then + mp.osd_message("External subtitles not found") + return + end + + result_list.list = { + { + sub = nil, + time = mp.get_property_native("time-pos"), + ass = "Original position" + } + } + result_list.selected = 1 + result_list.live = live + + local closest_lower_index = 1 + local closest_lower_time = nil + local cur_time = mp.get_property_native("time-pos") + + local pat = "(" .. make_nocase_pattern(query) .. ")" + for _, sub in ipairs(subs) do + for _, sub_line in ipairs(sub.lines) do + if query == "*" or utf8.match(sub_line.text, pat) then + local sub_time = adjust_sub_time(sub_line.time) + + table.insert(result_list.list, { + sub = sub, + original_time = sub_line.time, + time = sub_time + 0.01, -- to ensure that the subtitle is visible + formatter = function(style_reset) + local sub_text = result_list.ass_escape(format_time(sub_time) .. ": ") .. + highlight_match(sub_line.text, query, style_reset) + + if #subs > 1 then + sub_text = "[" .. sub.prefix .. "] " .. sub_text + end + + return sub_text + end + }) + + if sub_time <= cur_time and (closest_lower_time == nil or closest_lower_time < sub_time) then + closest_lower_time = sub_time + closest_lower_index = #result_list.list + end + end + end + end + + result_list.selected = closest_lower_index + result_list.header = "Search results for \"" .. query .. "\"\\N ------------------------------------" + result_list.header = result_list.header .. "\\NENTER to jump to subtitle, Ctrl+Shift+Enter to adjust subtitle timing to selected line" + + result_list:update() + result_list:open() + end) +end + +mp.register_script_message('start-search', function() + if input_console.is_repl_active() then + input_console.set_active(false) + else + input_console.set_enter_handler(function(query) + update_search_results_async(query, false) + end) + input_console.set_active(true) + end +end) + +mp.register_script_message('show-all-lines', function() + update_search_results_async("*", true) +end) + +local function get_current_subtitle_index(list, pos) + local closest_lower_index = 1 + local closest_lower_time = nil + for i, item in ipairs(list) do + if item.time <= pos and (closest_lower_time == nil or closest_lower_time < item.time) then + closest_lower_time = item.time + closest_lower_index = i + end + end + return closest_lower_index +end + +mp.observe_property("time-pos", "native", function(_, pos) + if not result_list.hidden and result_list.live and pos ~= nil then + local index = get_current_subtitle_index(result_list.list, pos) + if index > 1 then + result_list.selected = index + result_list:update() + end + end +end) diff --git a/mac/.config/mpv/scripts/thumbfast.lua b/mac/.config/mpv/scripts/thumbfast.lua new file mode 100644 index 0000000..58d1870 --- /dev/null +++ b/mac/.config/mpv/scripts/thumbfast.lua @@ -0,0 +1,951 @@ +-- thumbfast.lua +-- +-- High-performance on-the-fly thumbnailer +-- +-- Built for easy integration in third-party UIs. + +--[[ +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. +]] + +local options = { + -- Socket path (leave empty for auto) + socket = "", + + -- Thumbnail path (leave empty for auto) + thumbnail = "", + + -- Maximum thumbnail generation size in pixels (scaled down to fit) + -- Values are scaled when hidpi is enabled + max_height = 200, + max_width = 200, + + -- Scale factor for thumbnail display size (requires mpv 0.38+) + -- Note that this is lower quality than increasing max_height and max_width + scale_factor = 1, + + -- Apply tone-mapping, no to disable + tone_mapping = "auto", + + -- Overlay id + overlay_id = 42, + + -- Spawn thumbnailer on file load for faster initial thumbnails + spawn_first = false, + + -- Close thumbnailer process after an inactivity period in seconds, 0 to disable + quit_after_inactivity = 0, + + -- Enable on network playback + network = false, + + -- Enable on audio playback + audio = false, + + -- Enable hardware decoding + hwdec = false, + + -- Windows only: use native Windows API to write to pipe (requires LuaJIT) + direct_io = false, + + -- Custom path to the mpv executable + mpv_path = "mpv" +} + +mp.utils = require "mp.utils" +mp.options = require "mp.options" +mp.options.read_options(options, "thumbfast") + +local properties = {} +local pre_0_30_0 = mp.command_native_async == nil +local pre_0_33_0 = true +local support_media_control = mp.get_property_native("media-controls") ~= nil + +function subprocess(args, async, callback) + callback = callback or function() end + + if not pre_0_30_0 then + if async then + return mp.command_native_async({name = "subprocess", playback_only = true, args = args}, callback) + else + return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = args}) + end + else + if async then + return mp.utils.subprocess_detached({args = args}, callback) + else + return mp.utils.subprocess({args = args}) + end + end +end + +local winapi = {} +if options.direct_io then + local ffi_loaded, ffi = pcall(require, "ffi") + if ffi_loaded then + winapi = { + ffi = ffi, + C = ffi.C, + bit = require("bit"), + socket_wc = "", + + -- WinAPI constants + CP_UTF8 = 65001, + GENERIC_WRITE = 0x40000000, + OPEN_EXISTING = 3, + FILE_FLAG_WRITE_THROUGH = 0x80000000, + FILE_FLAG_NO_BUFFERING = 0x20000000, + PIPE_NOWAIT = ffi.new("unsigned long[1]", 0x00000001), + + INVALID_HANDLE_VALUE = ffi.cast("void*", -1), + + -- don't care about how many bytes WriteFile wrote, so allocate something to store the result once + _lpNumberOfBytesWritten = ffi.new("unsigned long[1]"), + } + -- cache flags used in run() to avoid bor() call + winapi._createfile_pipe_flags = winapi.bit.bor(winapi.FILE_FLAG_WRITE_THROUGH, winapi.FILE_FLAG_NO_BUFFERING) + + ffi.cdef[[ + void* __stdcall CreateFileW(const wchar_t *lpFileName, unsigned long dwDesiredAccess, unsigned long dwShareMode, void *lpSecurityAttributes, unsigned long dwCreationDisposition, unsigned long dwFlagsAndAttributes, void *hTemplateFile); + bool __stdcall WriteFile(void *hFile, const void *lpBuffer, unsigned long nNumberOfBytesToWrite, unsigned long *lpNumberOfBytesWritten, void *lpOverlapped); + bool __stdcall CloseHandle(void *hObject); + bool __stdcall SetNamedPipeHandleState(void *hNamedPipe, unsigned long *lpMode, unsigned long *lpMaxCollectionCount, unsigned long *lpCollectDataTimeout); + int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); + ]] + + winapi.MultiByteToWideChar = function(MultiByteStr) + if MultiByteStr then + local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, nil, 0) + if utf16_len > 0 then + local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len) + if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then + return utf16_str + end + end + end + return "" + end + + else + options.direct_io = false + end +end + +local file +local file_bytes = 0 +local spawned = false +local disabled = false +local force_disabled = false +local spawn_waiting = false +local spawn_working = false +local script_written = false + +local dirty = false + +local x, y +local last_x, last_y + +local last_seek_time + +local effective_w, effective_h = options.max_width, options.max_height +local real_w, real_h +local last_real_w, last_real_h + +local script_name + +local show_thumbnail = false + +local filters_reset = {["lavfi-crop"]=true, ["crop"]=true} +local filters_runtime = {["hflip"]=true, ["vflip"]=true} +local filters_all = {["hflip"]=true, ["vflip"]=true, ["lavfi-crop"]=true, ["crop"]=true} + +local tone_mappings = {["none"]=true, ["clip"]=true, ["linear"]=true, ["gamma"]=true, ["reinhard"]=true, ["hable"]=true, ["mobius"]=true} +local last_tone_mapping + +local last_vf_reset = "" +local last_vf_runtime = "" + +local last_rotate = 0 + +local par = "" +local last_par = "" + +local last_crop = nil + +local last_has_vid = 0 +local has_vid = 0 + +local file_timer +local file_check_period = 1/60 + +local allow_fast_seek = true + +local client_script = [=[ +#!/usr/bin/env bash +MPV_IPC_FD=0; MPV_IPC_PATH="%s" +trap "kill 0" EXIT +while [[ $# -ne 0 ]]; do case $1 in --mpv-ipc-fd=*) MPV_IPC_FD=${1/--mpv-ipc-fd=/} ;; esac; shift; done +if echo "print-text thumbfast" >&"$MPV_IPC_FD"; then echo -n > "$MPV_IPC_PATH"; tail -f "$MPV_IPC_PATH" >&"$MPV_IPC_FD" & while read -r -u "$MPV_IPC_FD" 2>/dev/null; do :; done; fi +]=] + +local function get_os() + local raw_os_name = "" + + if jit and jit.os and jit.arch then + raw_os_name = jit.os + else + if package.config:sub(1,1) == "\\" then + -- Windows + local env_OS = os.getenv("OS") + if env_OS then + raw_os_name = env_OS + end + else + raw_os_name = subprocess({"uname", "-s"}).stdout + end + end + + raw_os_name = (raw_os_name):lower() + + local os_patterns = { + ["windows"] = "windows", + ["linux"] = "linux", + + ["osx"] = "darwin", + ["mac"] = "darwin", + ["darwin"] = "darwin", + + ["^mingw"] = "windows", + ["^cygwin"] = "windows", + + ["bsd$"] = "darwin", + ["sunos"] = "darwin" + } + + -- Default to linux + local str_os_name = "linux" + + for pattern, name in pairs(os_patterns) do + if raw_os_name:match(pattern) then + str_os_name = name + break + end + end + + return str_os_name +end + +local os_name = mp.get_property("platform") or get_os() + +local path_separator = os_name == "windows" and "\\" or "/" + +if options.socket == "" then + if os_name == "windows" then + options.socket = "thumbfast" + else + options.socket = "/tmp/thumbfast" + end +end + +if options.thumbnail == "" then + if os_name == "windows" then + options.thumbnail = os.getenv("TEMP").."\\thumbfast.out" + else + options.thumbnail = "/tmp/thumbfast.out" + end +end + +local unique = mp.utils.getpid() + +options.socket = options.socket .. unique +options.thumbnail = options.thumbnail .. unique + +if options.direct_io then + if os_name == "windows" then + winapi.socket_wc = winapi.MultiByteToWideChar("\\\\.\\pipe\\" .. options.socket) + end + + if winapi.socket_wc == "" then + options.direct_io = false + end +end + +options.scale_factor = math.floor(options.scale_factor) + +local mpv_path = options.mpv_path +local frontend_path + +if mpv_path == "mpv" and os_name == "windows" then + frontend_path = mp.get_property_native("user-data/frontend/process-path") + mpv_path = frontend_path or mpv_path +end + +if mpv_path == "mpv" and os_name == "darwin" and unique then + -- TODO: look into ~~osxbundle/ + mpv_path = string.gsub(subprocess({"ps", "-o", "comm=", "-p", tostring(unique)}).stdout, "[\n\r]", "") + if mpv_path ~= "mpv" then + mpv_path = string.gsub(mpv_path, "/mpv%-bundle$", "/mpv") + local mpv_bin = mp.utils.file_info("/usr/local/mpv") + if mpv_bin and mpv_bin.is_file then + mpv_path = "/usr/local/mpv" + else + local mpv_app = mp.utils.file_info("/Applications/mpv.app/Contents/MacOS/mpv") + if mpv_app and mpv_app.is_file then + mp.msg.warn("symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`") + else + mp.msg.warn("drag to your Applications folder and symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`") + end + end + end +end + +local function vo_tone_mapping() + local passes = mp.get_property_native("vo-passes") + if passes and passes["fresh"] then + for k, v in pairs(passes["fresh"]) do + for k2, v2 in pairs(v) do + if k2 == "desc" and v2 then + local tone_mapping = string.match(v2, "([0-9a-z.-]+) tone map") + if tone_mapping then + return tone_mapping + end + end + end + end + end +end + +local function vf_string(filters, full) + local vf = "" + local vf_table = properties["vf"] + + if (properties["video-crop"] or "") ~= "" then + vf = "lavfi-crop="..string.gsub(properties["video-crop"], "(%d*)x?(%d*)%+(%d+)%+(%d+)", "w=%1:h=%2:x=%3:y=%4").."," + local width = properties["video-out-params"] and properties["video-out-params"]["dw"] + local height = properties["video-out-params"] and properties["video-out-params"]["dh"] + if width and height then + vf = string.gsub(vf, "w=:h=:", "w="..width..":h="..height..":") + end + end + + if vf_table and #vf_table > 0 then + for i = #vf_table, 1, -1 do + if filters[vf_table[i].name] then + local args = "" + for key, value in pairs(vf_table[i].params) do + if args ~= "" then + args = args .. ":" + end + args = args .. key .. "=" .. value + end + vf = vf .. vf_table[i].name .. "=" .. args .. "," + end + end + end + + if (full and options.tone_mapping ~= "no") or options.tone_mapping == "auto" then + if properties["video-params"] and properties["video-params"]["primaries"] == "bt.2020" then + local tone_mapping = options.tone_mapping + if tone_mapping == "auto" then + tone_mapping = last_tone_mapping or properties["tone-mapping"] + if tone_mapping == "auto" and properties["current-vo"] == "gpu-next" then + tone_mapping = vo_tone_mapping() + end + end + if not tone_mappings[tone_mapping] then + tone_mapping = "hable" + end + last_tone_mapping = tone_mapping + vf = vf .. "zscale=transfer=linear,format=gbrpf32le,tonemap="..tone_mapping..",zscale=transfer=bt709," + end + end + + if full then + vf = vf.."scale=w="..effective_w..":h="..effective_h..par..",pad=w="..effective_w..":h="..effective_h..":x=-1:y=-1,format=bgra" + end + + return vf +end + +local function calc_dimensions() + local width = properties["video-out-params"] and properties["video-out-params"]["dw"] + local height = properties["video-out-params"] and properties["video-out-params"]["dh"] + if not width or not height then return end + + local scale = properties["display-hidpi-scale"] or 1 + + if width / height > options.max_width / options.max_height then + effective_w = math.floor(options.max_width * scale + 0.5) + effective_h = math.floor(height / width * effective_w + 0.5) + else + effective_h = math.floor(options.max_height * scale + 0.5) + effective_w = math.floor(width / height * effective_h + 0.5) + end + + local v_par = properties["video-out-params"] and properties["video-out-params"]["par"] or 1 + if v_par == 1 then + par = ":force_original_aspect_ratio=decrease" + else + par = "" + end +end + +local info_timer = nil + +local function info(w, h) + local rotate = properties["video-params"] and properties["video-params"]["rotate"] + local image = properties["current-tracks/video"] and properties["current-tracks/video"]["image"] + local albumart = image and properties["current-tracks/video"]["albumart"] + + disabled = (w or 0) == 0 or (h or 0) == 0 or + has_vid == 0 or + (properties["demuxer-via-network"] and not options.network) or + (albumart and not options.audio) or + (image and not albumart) or + force_disabled + + if info_timer then + info_timer:kill() + info_timer = nil + elseif has_vid == 0 or (rotate == nil and not disabled) then + info_timer = mp.add_timeout(0.05, function() info(w, h) end) + end + + local json, err = mp.utils.format_json({width=w * options.scale_factor, height=h * options.scale_factor, scale_factor=options.scale_factor, disabled=disabled, available=true, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) + if pre_0_30_0 then + mp.command_native({"script-message", "thumbfast-info", json}) + else + mp.command_native_async({"script-message", "thumbfast-info", json}, function() end) + end +end + +local function remove_thumbnail_files() + if file then + file:close() + file = nil + file_bytes = 0 + end + os.remove(options.thumbnail) + os.remove(options.thumbnail..".bgra") +end + +local activity_timer + +local function spawn(time) + if disabled then return end + + local path = properties["path"] + if path == nil then return end + + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + + local open_filename = properties["stream-open-filename"] + local ytdl = open_filename and properties["demuxer-via-network"] and path ~= open_filename + if ytdl then + path = open_filename + end + + remove_thumbnail_files() + + local vid = properties["vid"] + has_vid = vid or 0 + + local args = { + mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal", + "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", + "--edition="..(properties["edition"] or "auto"), "--vid="..(vid or "auto"), "--no-sub", "--no-audio", + "--start="..time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes", + "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", + "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", "--hwdec="..(options.hwdec and "auto" or "no"), + "--vf="..vf_string(filters_all, true), + "--sws-scaler=fast-bilinear", + "--video-rotate="..last_rotate, + "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail + } + + if not pre_0_30_0 then + table.insert(args, "--sws-allow-zimg=no") + end + + if support_media_control then + table.insert(args, "--media-controls=no") + end + + if os_name == "darwin" and properties["macos-app-activation-policy"] then + table.insert(args, "--macos-app-activation-policy=accessory") + end + + if os_name == "windows" or pre_0_33_0 then + table.insert(args, "--input-ipc-server="..options.socket) + elseif not script_written then + local client_script_path = options.socket..".run" + local script = io.open(client_script_path, "w+") + if script == nil then + mp.msg.error("client script write failed") + return + else + script_written = true + script:write(string.format(client_script, options.socket)) + script:close() + subprocess({"chmod", "+x", client_script_path}, true) + table.insert(args, "--scripts="..client_script_path) + end + else + local client_script_path = options.socket..".run" + table.insert(args, "--scripts="..client_script_path) + end + + table.insert(args, "--") + table.insert(args, path) + + spawned = true + spawn_waiting = true + + subprocess(args, true, + function(success, result) + if spawn_waiting and (success == false or (result.status ~= 0 and result.status ~= -2)) then + spawned = false + spawn_waiting = false + options.tone_mapping = "no" + mp.msg.error("mpv subprocess create failed") + if not spawn_working then -- notify users of required configuration + if options.mpv_path == "mpv" then + if properties["current-vo"] == "libmpv" then + if options.mpv_path == mpv_path then -- attempt to locate ImPlay + mpv_path = "ImPlay" + spawn(time) + else -- ImPlay not in path + if os_name ~= "darwin" then + force_disabled = true + info(real_w or effective_w, real_h or effective_h) + end + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay") + end + else + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + if os_name == "windows" and frontend_path == nil then + mp.commandv("script-message-to", "mpvnet", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20) + mp.commandv("script-message", "mpv.net", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20) + end + end + else + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + -- found ImPlay but not defined in config + mp.commandv("script-message-to", "implay", "show-message", "thumbfast", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay") + end + end + elseif success == true and (result.status == 0 or result.status == -2) then + if not spawn_working and properties["current-vo"] == "libmpv" and options.mpv_path ~= mpv_path then + mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay") + end + spawn_working = true + spawn_waiting = false + end + end + ) +end + +local function run(command) + if not spawned then return end + + if options.direct_io then + local hPipe = winapi.C.CreateFileW(winapi.socket_wc, winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, winapi._createfile_pipe_flags, nil) + if hPipe ~= winapi.INVALID_HANDLE_VALUE then + local buf = command .. "\n" + winapi.C.SetNamedPipeHandleState(hPipe, winapi.PIPE_NOWAIT, nil, nil) + winapi.C.WriteFile(hPipe, buf, #buf + 1, winapi._lpNumberOfBytesWritten, nil) + winapi.C.CloseHandle(hPipe) + end + + return + end + + local command_n = command.."\n" + + if os_name == "windows" then + if file and file_bytes + #command_n >= 4096 then + file:close() + file = nil + file_bytes = 0 + end + if not file then + file = io.open("\\\\.\\pipe\\"..options.socket, "r+b") + end + elseif pre_0_33_0 then + subprocess({"/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket}) + return + elseif not file then + file = io.open(options.socket, "r+") + end + if file then + file_bytes = file:seek("end") + file:write(command_n) + file:flush() + end +end + +local function draw(w, h, script) + if not w or not show_thumbnail then return end + if x ~= nil then + local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil + if pre_0_30_0 then + mp.command_native({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}) + else + mp.command_native_async({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}, function() end) + end + elseif script then + local json, err = mp.utils.format_json({width=w, height=h, scale_factor=options.scale_factor, x=x, y=y, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) + mp.commandv("script-message-to", script, "thumbfast-render", json) + end +end + +local function real_res(req_w, req_h, filesize) + local count = filesize / 4 + local diff = (req_w * req_h) - count + + if (properties["video-params"] and properties["video-params"]["rotate"] or 0) % 180 == 90 then + req_w, req_h = req_h, req_w + end + + if diff == 0 then + return req_w, req_h + else + local threshold = 5 -- throw out results that change too much + local long_side, short_side = req_w, req_h + if req_h > req_w then + long_side, short_side = req_h, req_w + end + for a = short_side, short_side - threshold, -1 do + if count % a == 0 then + local b = count / a + if long_side - b < threshold then + if req_h < req_w then return b, a else return a, b end + end + end + end + return nil + end +end + +local function move_file(from, to) + if os_name == "windows" then + os.remove(to) + end + -- move the file because it can get overwritten while overlay-add is reading it, and crash the player + os.rename(from, to) +end + +local function seek(fast) + if last_seek_time then + run("async seek " .. last_seek_time .. (fast and " absolute+keyframes" or " absolute+exact")) + end +end + +local seek_period = 3/60 +local seek_period_counter = 0 +local seek_timer +seek_timer = mp.add_periodic_timer(seek_period, function() + if seek_period_counter == 0 then + seek(allow_fast_seek) + seek_period_counter = 1 + else + if seek_period_counter == 2 then + if allow_fast_seek then + seek_timer:kill() + seek() + end + else seek_period_counter = seek_period_counter + 1 end + end +end) +seek_timer:kill() + +local function request_seek() + if seek_timer:is_enabled() then + seek_period_counter = 0 + else + seek_timer:resume() + seek(allow_fast_seek) + seek_period_counter = 1 + end +end + +local function check_new_thumb() + -- the slave might start writing to the file after checking existance and + -- validity but before actually moving the file, so move to a temporary + -- location before validity check to make sure everything stays consistant + -- and valid thumbnails don't get overwritten by invalid ones + local tmp = options.thumbnail..".tmp" + move_file(options.thumbnail, tmp) + local finfo = mp.utils.file_info(tmp) + if not finfo then return false end + spawn_waiting = false + local w, h = real_res(effective_w, effective_h, finfo.size) + if w then -- only accept valid thumbnails + move_file(tmp, options.thumbnail..".bgra") + + real_w, real_h = w, h + if real_w and (real_w ~= last_real_w or real_h ~= last_real_h) then + last_real_w, last_real_h = real_w, real_h + info(real_w, real_h) + end + if not show_thumbnail then + file_timer:kill() + end + return true + end + + return false +end + +file_timer = mp.add_periodic_timer(file_check_period, function() + if check_new_thumb() then + draw(real_w, real_h, script_name) + end +end) +file_timer:kill() + +local function clear() + file_timer:kill() + seek_timer:kill() + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + last_seek_time = nil + show_thumbnail = false + last_x = nil + last_y = nil + if script_name then return end + if pre_0_30_0 then + mp.command_native({"overlay-remove", options.overlay_id}) + else + mp.command_native_async({"overlay-remove", options.overlay_id}, function() end) + end +end + +local function quit() + activity_timer:kill() + if show_thumbnail then + activity_timer:resume() + return + end + run("quit") + spawned = false + real_w, real_h = nil, nil + clear() +end + +activity_timer = mp.add_timeout(options.quit_after_inactivity, quit) +activity_timer:kill() + +local function thumb(time, r_x, r_y, script) + if disabled then return end + + time = tonumber(time) + if time == nil then return end + + if r_x == "" or r_y == "" then + x, y = nil, nil + else + x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5) + end + + script_name = script + if last_x ~= x or last_y ~= y or not show_thumbnail then + show_thumbnail = true + last_x, last_y = x, y + draw(real_w, real_h, script) + end + + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + + if time == last_seek_time then return end + last_seek_time = time + if not spawned then spawn(time) end + request_seek() + if not file_timer:is_enabled() then file_timer:resume() end +end + +local function watch_changes() + if not dirty or not properties["video-out-params"] then return end + dirty = false + + local old_w = effective_w + local old_h = effective_h + + calc_dimensions() + + local vf_reset = vf_string(filters_reset) + local rotate = properties["video-rotate"] or 0 + + local resized = old_w ~= effective_w or + old_h ~= effective_h or + last_vf_reset ~= vf_reset or + (last_rotate % 180) ~= (rotate % 180) or + par ~= last_par or last_crop ~= properties["video-crop"] + + if resized then + last_rotate = rotate + info(effective_w, effective_h) + elseif last_has_vid ~= has_vid and has_vid ~= 0 then + info(effective_w, effective_h) + end + + if spawned then + if resized then + -- mpv doesn't allow us to change output size + local seek_time = last_seek_time + run("quit") + clear() + spawned = false + spawn(seek_time or mp.get_property_number("time-pos", 0)) + file_timer:resume() + else + if rotate ~= last_rotate then + run("set video-rotate "..rotate) + end + local vf_runtime = vf_string(filters_runtime) + if vf_runtime ~= last_vf_runtime then + run("vf set "..vf_string(filters_all, true)) + last_vf_runtime = vf_runtime + end + end + else + last_vf_runtime = vf_string(filters_runtime) + end + + last_vf_reset = vf_reset + last_rotate = rotate + last_par = par + last_crop = properties["video-crop"] + last_has_vid = has_vid + + if not spawned and not disabled and options.spawn_first and resized then + spawn(mp.get_property_number("time-pos", 0)) + file_timer:resume() + end +end + +local function update_property(name, value) + properties[name] = value +end + +local function update_property_dirty(name, value) + properties[name] = value + dirty = true + if name == "tone-mapping" then + last_tone_mapping = nil + end +end + +local function update_tracklist(name, value) + -- current-tracks shim + for _, track in ipairs(value) do + if track.type == "video" and track.selected then + properties["current-tracks/video"] = track + return + end + end +end + +local function sync_changes(prop, val) + update_property(prop, val) + if val == nil then return end + + if type(val) == "boolean" then + if prop == "vid" then + has_vid = 0 + last_has_vid = 0 + info(effective_w, effective_h) + clear() + return + end + val = val and "yes" or "no" + end + + if prop == "vid" then + has_vid = 1 + end + + if not spawned then return end + + run("set "..prop.." "..val) + dirty = true +end + +local function file_load() + clear() + spawned = false + real_w, real_h = nil, nil + last_real_w, last_real_h = nil, nil + last_tone_mapping = nil + last_seek_time = nil + if info_timer then + info_timer:kill() + info_timer = nil + end + + calc_dimensions() + info(effective_w, effective_h) +end + +local function shutdown() + run("quit") + remove_thumbnail_files() + if os_name ~= "windows" then + os.remove(options.socket) + os.remove(options.socket..".run") + end +end + +local function on_duration(prop, val) + allow_fast_seek = (val or 30) >= 30 +end + +mp.observe_property("current-tracks/video", "native", function(name, value) + if pre_0_33_0 then + mp.unobserve_property(update_tracklist) + pre_0_33_0 = false + end + update_property(name, value) +end) + +mp.observe_property("track-list", "native", update_tracklist) +mp.observe_property("display-hidpi-scale", "native", update_property_dirty) +mp.observe_property("video-out-params", "native", update_property_dirty) +mp.observe_property("video-params", "native", update_property_dirty) +mp.observe_property("vf", "native", update_property_dirty) +mp.observe_property("tone-mapping", "native", update_property_dirty) +mp.observe_property("demuxer-via-network", "native", update_property) +mp.observe_property("stream-open-filename", "native", update_property) +mp.observe_property("macos-app-activation-policy", "native", update_property) +mp.observe_property("current-vo", "native", update_property) +mp.observe_property("video-rotate", "native", update_property) +mp.observe_property("video-crop", "native", update_property) +mp.observe_property("path", "native", update_property) +mp.observe_property("vid", "native", sync_changes) +mp.observe_property("edition", "native", sync_changes) +mp.observe_property("duration", "native", on_duration) + +mp.register_script_message("thumb", thumb) +mp.register_script_message("clear", clear) + +mp.register_event("file-loaded", file_load) +mp.register_event("shutdown", shutdown) + +mp.register_idle(watch_changes) diff --git a/mac/.config/mpv/scripts/user-input.lua b/mac/.config/mpv/scripts/user-input.lua new file mode 100644 index 0000000..656c3ab --- /dev/null +++ b/mac/.config/mpv/scripts/user-input.lua @@ -0,0 +1,890 @@ +local mp = require("mp") +local msg = require("mp.msg") +local utils = require("mp.utils") +local options = require("mp.options") + +-- Default options +local opts = { + -- All drawing is scaled by this value, including the text borders and the + -- cursor. Change it if you have a high-DPI display. + scale = 1, + -- Set the font used for the REPL and the console. This probably doesn't + -- have to be a monospaced font. + font = "", + -- Set the font size used for the REPL and the console. This will be + -- multiplied by "scale." + font_size = 16, +} + +options.read_options(opts, "user_input") + +local API_VERSION = "0.1.0" +local API_MAJOR_MINOR = API_VERSION:match("%d+%.%d+") + +local co = nil +local queue = {} +local active_ids = {} +local histories = {} +local request = nil + +local line = "" + +--[[ + The below code is a modified implementation of text input from mpv's console.lua: + https://github.com/mpv-player/mpv/blob/7ca14d646c7e405f3fb1e44600e2a67fc4607238/player/lua/console.lua + + Modifications: + removed support for log messages, sending commands, tab complete, help commands + removed update timer + Changed esc key to call handle_esc function + handle_esc and handle_enter now resume the main coroutine with a response table + made history specific to request ids + localised all functions - reordered some to fit + keybindings use new names +]] +-- + +------------------------------START ORIGINAL MPV CODE----------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- + +-- Copyright (C) 2019 the mpv developers +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +local assdraw = require("mp.assdraw") + +local function detect_platform() + local o = {} + -- Kind of a dumb way of detecting the platform but whatever + if mp.get_property_native("options/vo-mmcss-profile", o) ~= o then + return "windows" + elseif mp.get_property_native("options/macos-force-dedicated-gpu", o) ~= o then + return "macos" + elseif os.getenv("WAYLAND_DISPLAY") then + return "wayland" + end + return "x11" +end + +-- Pick a better default font for Windows and macOS +local platform = detect_platform() +if platform == "windows" then + opts.font = "Consolas" +elseif platform == "macos" then + opts.font = "Menlo" +else + opts.font = "monospace" +end + +local repl_active = false +local insert_mode = false +local cursor = 1 +local key_bindings = {} +local global_margin_y = 0 + +-- Escape a string for verbatim display on the OSD +local function ass_escape(str) + -- 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 + str = str:gsub("\\", "\\\239\187\191") + str = str:gsub("{", "\\{") + str = str:gsub("}", "\\}") + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub("\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") + return str +end + +-- Render the REPL and console as an ASS OSD +local function update() + local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) + + dpi_scale = dpi_scale * opts.scale + + local screenx, screeny, aspect = mp.get_osd_size() + screenx = screenx / dpi_scale + screeny = screeny / dpi_scale + + -- Clear the OSD if the REPL is not active + if not repl_active then + mp.set_osd_ass(screenx, screeny, "") + return + end + + local ass = assdraw.ass_new() + local style = "{\\r" + .. "\\1a&H00&\\3a&H00&\\4a&H99&" + .. "\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&" + .. "\\fn" + .. opts.font + .. "\\fs" + .. opts.font_size + .. "\\bord1\\xshad0\\yshad1\\fsp0\\q1}" + + local queue_style = "{\\r" + .. "\\1a&H00&\\3a&H00&\\4a&H99&" + .. "\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&" + .. "\\fn" + .. opts.font + .. "\\fs" + .. opts.font_size + .. "\\c&H66ccff&" + .. "\\bord1\\xshad0\\yshad1\\fsp0\\q1}" + + -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor + -- inline with the surrounding text, but it sets the advance to the width + -- of the drawing. So the cursor doesn't affect layout too much, make it as + -- thin as possible and make it appear to be 1px wide by giving it 0.5px + -- horizontal borders. + local cheight = opts.font_size * 8 + local cglyph = "{\\r" + .. "\\1a&H44&\\3a&H44&\\4a&H99&" + .. "\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&" + .. "\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}" + .. "m 0 0 l 1 0 l 1 " + .. cheight + .. " l 0 " + .. cheight + .. "{\\p0}" + local before_cur = ass_escape(line:sub(1, cursor - 1)) + local after_cur = ass_escape(line:sub(cursor)) + + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margin_y * screeny) + + if #queue == 2 then + ass:append(queue_style .. string.format("There is 1 more request queued\\N")) + elseif #queue > 2 then + ass:append(queue_style .. string.format("There are %d more requests queued\\N", #queue - 1)) + end + ass:append(style .. request.text .. "\\N") + ass:append("> " .. before_cur) + ass:append(cglyph) + ass:append(style .. after_cur) + + -- Redraw the cursor with the REPL text invisible. This will make the + -- cursor appear in front of the text. + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2) + ass:append(style .. "{\\alpha&HFF&}> " .. before_cur) + ass:append(cglyph) + ass:append(style .. "{\\alpha&HFF&}" .. after_cur) + + mp.set_osd_ass(screenx, screeny, ass.text) +end + +-- Naive helper function to find the next UTF-8 character in 'str' after 'pos' +-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. +local function next_utf8(str, pos) + if pos > str:len() then + return pos + end + repeat + pos = pos + 1 + until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' +local function prev_utf8(str, pos) + if pos <= 1 then + return pos + end + repeat + pos = pos - 1 + until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- Insert a character at the current cursor position (any_unicode) +local function handle_char_input(c) + if insert_mode then + line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) + else + line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) + end + cursor = cursor + #c + update() +end + +-- Remove the character behind the cursor (Backspace) +local function handle_backspace() + if cursor <= 1 then + return + end + local prev = prev_utf8(line, cursor) + line = line:sub(1, prev - 1) .. line:sub(cursor) + cursor = prev + update() +end + +-- Remove the character in front of the cursor (Del) +local function handle_del() + if cursor > line:len() then + return + end + line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) + update() +end + +-- Toggle insert mode (Ins) +local function handle_ins() + insert_mode = not insert_mode +end + +-- Move the cursor to the next character (Right) +local function next_char(amount) + cursor = next_utf8(line, cursor) + update() +end + +-- Move the cursor to the previous character (Left) +local function prev_char(amount) + cursor = prev_utf8(line, cursor) + update() +end + +-- Clear the current line (Ctrl+C) +local function clear() + line = "" + cursor = 1 + insert_mode = false + request.history.pos = #request.history.list + 1 + update() +end + +-- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D) +local function maybe_exit() + if line == "" then + else + handle_del() + end +end + +local function handle_esc() + coroutine.resume(co, { + line = nil, + err = "exited", + }) +end + +-- Run the current command and clear the line (Enter) +local function handle_enter() + if request.history.list[#request.history.list] ~= line and line ~= "" then + request.history.list[#request.history.list + 1] = line + end + coroutine.resume(co, { + line = line, + }) +end + +-- Go to the specified position in the command history +local function go_history(new_pos) + local old_pos = request.history.pos + request.history.pos = new_pos + + -- Restrict the position to a legal value + if request.history.pos > #request.history.list + 1 then + request.history.pos = #request.history.list + 1 + elseif request.history.pos < 1 then + request.history.pos = 1 + end + + -- Do nothing if the history position didn't actually change + if request.history.pos == old_pos then + return + end + + -- If the user was editing a non-history line, save it as the last history + -- entry. This makes it much less frustrating to accidentally hit Up/Down + -- while editing a line. + if old_pos == #request.history.list + 1 and line ~= "" and request.history.list[#request.history.list] ~= line then + request.history.list[#request.history.list + 1] = line + end + + -- Now show the history line (or a blank line for #history + 1) + if request.history.pos <= #request.history.list then + line = request.history.list[request.history.pos] + else + line = "" + end + cursor = line:len() + 1 + insert_mode = false + update() +end + +-- Go to the specified relative position in the command history (Up, Down) +local function move_history(amount) + go_history(request.history.pos + amount) +end + +-- Go to the first command in the command history (PgUp) +local function handle_pgup() + go_history(1) +end + +-- Stop browsing history and start editing a blank line (PgDown) +local function handle_pgdown() + go_history(#request.history.list + 1) +end + +-- Move to the start of the current word, or if already at the start, the start +-- of the previous word. (Ctrl+Left) +local function prev_word() + -- This is basically the same as next_word() but backwards, so reverse the + -- string in order to do a "backwards" find. This wouldn't be as annoying + -- to do if Lua didn't insist on 1-based indexing. + cursor = line:len() - select(2, line:reverse():find("%s*[^%s]*", line:len() - cursor + 2)) + 1 + update() +end + +-- Move to the end of the current word, or if already at the end, the end of +-- the next word. (Ctrl+Right) +local function next_word() + cursor = select(2, line:find("%s*[^%s]*", cursor)) + 1 + update() +end + +-- Move the cursor to the beginning of the line (HOME) +local function go_home() + cursor = 1 + update() +end + +-- Move the cursor to the end of the line (END) +local function go_end() + cursor = line:len() + 1 + update() +end + +-- Delete from the cursor to the beginning of the word (Ctrl+Backspace) +local function del_word() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + before_cur = before_cur:gsub("[^%s]+%s*$", "", 1) + line = before_cur .. after_cur + cursor = before_cur:len() + 1 + update() +end + +-- Delete from the cursor to the end of the word (Ctrl+Del) +local function del_next_word() + if cursor > line:len() then + return + end + + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + after_cur = after_cur:gsub("^%s*[^%s]+", "", 1) + line = before_cur .. after_cur + update() +end + +-- Delete from the cursor to the end of the line (Ctrl+K) +local function del_to_eol() + line = line:sub(1, cursor - 1) + update() +end + +-- Delete from the cursor back to the start of the line (Ctrl+U) +local function del_to_start() + line = line:sub(cursor) + cursor = 1 + update() +end + +-- Returns a string of UTF-8 text from the clipboard (or the primary selection) +local function get_clipboard(clip) + if platform == "x11" then + local res = utils.subprocess({ + args = { "xclip", "-selection", clip and "clipboard" or "primary", "-out" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "wayland" then + local res = utils.subprocess({ + args = { "wl-paste", clip and "-n" or "-np" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "windows" then + local res = utils.subprocess({ + args = { + "powershell", + "-NoProfile", + "-Command", + [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + + $clip = "" + if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + } else { + Add-Type -AssemblyName PresentationCore + $clip = [Windows.Clipboard]::GetText() + } + + $clip = $clip -Replace "`r","" + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]], + }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == "macos" then + local res = utils.subprocess({ + args = { "pbpaste" }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + end + return "" +end + +-- Paste text from the window-system's clipboard. 'clip' determines whether the +-- clipboard or the primary selection buffer is used (on X11 and Wayland only.) +local function paste(clip) + local text = get_clipboard(clip) + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + line = before_cur .. text .. after_cur + cursor = cursor + text:len() + update() +end + +-- List of input bindings. This is a weird mashup between common GUI text-input +-- bindings and readline bindings. +local function get_bindings() + local bindings = { + { "esc", handle_esc }, + { "enter", handle_enter }, + { "kp_enter", handle_enter }, + { + "shift+enter", + function() + handle_char_input("\n") + end, + }, + { "ctrl+j", handle_enter }, + { "ctrl+m", handle_enter }, + { "bs", handle_backspace }, + { "shift+bs", handle_backspace }, + { "ctrl+h", handle_backspace }, + { "del", handle_del }, + { "shift+del", handle_del }, + { "ins", handle_ins }, + { + "shift+ins", + function() + paste(false) + end, + }, + { + "mbtn_mid", + function() + paste(false) + end, + }, + { + "left", + function() + prev_char() + end, + }, + { + "ctrl+b", + function() + prev_char() + end, + }, + { + "right", + function() + next_char() + end, + }, + { + "ctrl+f", + function() + next_char() + end, + }, + { + "up", + function() + move_history(-1) + end, + }, + { + "ctrl+p", + function() + move_history(-1) + end, + }, + { + "wheel_up", + function() + move_history(-1) + end, + }, + { + "down", + function() + move_history(1) + end, + }, + { + "ctrl+n", + function() + move_history(1) + end, + }, + { + "wheel_down", + function() + move_history(1) + end, + }, + { "wheel_left", function() end }, + { "wheel_right", function() end }, + { "ctrl+left", prev_word }, + { "alt+b", prev_word }, + { "ctrl+right", next_word }, + { "alt+f", next_word }, + { "ctrl+a", go_home }, + { "home", go_home }, + { "ctrl+e", go_end }, + { "end", go_end }, + { "pgup", handle_pgup }, + { "pgdwn", handle_pgdown }, + { "ctrl+c", clear }, + { "ctrl+d", maybe_exit }, + { "ctrl+k", del_to_eol }, + { "ctrl+u", del_to_start }, + { + "ctrl+v", + function() + paste(true) + end, + }, + { + "meta+v", + function() + paste(true) + end, + }, + { "ctrl+bs", del_word }, + { "ctrl+w", del_word }, + { "ctrl+del", del_next_word }, + { "alt+d", del_next_word }, + { + "kp_dec", + function() + handle_char_input(".") + end, + }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = { + "kp" .. i, + function() + handle_char_input("" .. i) + end, + } + end + + return bindings +end + +local function text_input(info) + if info.key_text and (info.event == "press" or info.event == "down" or info.event == "repeat") then + handle_char_input(info.key_text) + end +end + +local function define_key_bindings() + if #key_bindings > 0 then + return + end + for _, bind in ipairs(get_bindings()) do + -- Generate arbitrary name for removing the bindings later. + local name = "_userinput_" .. bind[1] + key_bindings[#key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2], { repeatable = true }) + end + mp.add_forced_key_binding("any_unicode", "_userinput_text", text_input, { repeatable = true, complex = true }) + key_bindings[#key_bindings + 1] = "_userinput_text" +end + +local function undefine_key_bindings() + for _, name in ipairs(key_bindings) do + mp.remove_key_binding(name) + end + key_bindings = {} +end + +-- Set the REPL visibility ("enable", Esc) +local function set_active(active) + if active == repl_active then + return + end + if active then + repl_active = true + insert_mode = false + define_key_bindings() + else + clear() + repl_active = false + undefine_key_bindings() + collectgarbage() + end + update() +end + +mp.observe_property("user-data/osc/margins", "native", function(_, val) + if val then + global_margins = val + else + global_margins = { t = 0, b = 0 } + end + update() +end) + +-- Redraw the REPL when the OSD size changes. This is needed because the +-- PlayRes of the OSD will need to be adjusted. +mp.observe_property("osd-width", "native", update) +mp.observe_property("osd-height", "native", update) +mp.observe_property("display-hidpi-scale", "native", update) + +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +-------------------------------END ORIGINAL MPV CODE------------------------------------ + +--[[ + sends a response to the original script in the form of a json string + it is expected that all requests get a response, if the input is nil then err should say why + current error codes are: + exited the user closed the input instead of pressing Enter + already_queued a request with the specified id was already in the queue + cancelled a script cancelled the request + replace replaced by another request +]] +local function send_response(res) + if res.source then + mp.commandv("script-message-to", res.source, res.response, (utils.format_json(res))) + else + mp.commandv("script-message", res.response, (utils.format_json(res))) + end +end + +-- push new request onto the queue +-- if a request with the same id already exists and the queueable flag is not enabled then +-- a nil result will be returned to the function +function push_request(req) + if active_ids[req.id] then + if req.replace then + for i, q_req in ipairs(queue) do + if q_req.id == req.id then + send_response({ err = "replaced", response = q_req.response, source = q_req.source }) + queue[i] = req + if i == 1 then + request = req + end + end + end + update() + return + end + + if not req.queueable then + send_response({ err = "already_queued", response = req.response, source = req.source }) + return + end + end + + table.insert(queue, req) + active_ids[req.id] = (active_ids[req.id] or 0) + 1 + if #queue == 1 then + coroutine.resume(co) + end + update() +end + +-- safely removes an item from the queue and updates the set of active requests +function remove_request(index) + local req = table.remove(queue, index) + active_ids[req.id] = active_ids[req.id] - 1 + + if active_ids[req.id] == 0 then + active_ids[req.id] = nil + end + return req +end + +--an infinite loop that moves through the request queue +--uses a coroutine to handle asynchronous operations +local function driver() + while true do + while queue[1] do + request = queue[1] + line = request.default_input + cursor = request.cursor_pos + + if repl_active then + update() + else + set_active(true) + end + + res = coroutine.yield() + if res then + res.source, res.response = request.source, request.response + send_response(res) + remove_request(1) + end + end + + set_active(false) + coroutine.yield() + end +end + +co = coroutine.create(driver) + +--cancels any input request that returns true for the given predicate function +local function cancel_input_request(pred) + for i = #queue, 1, -1 do + if pred(i) then + req = remove_request(i) + send_response({ err = "cancelled", response = req.response, source = req.source }) + + --if we're removing the first item then that means the coroutine is waiting for a response + --we will need to tell the coroutine to resume, upon which it will move to the next request + --if there is something in the buffer then save it to the history before erasing it + if i == 1 then + local old_line = line + if old_line ~= "" then + table.insert(histories[req.id].list, old_line) + end + clear() + coroutine.resume(co) + end + end + end +end + +mp.register_script_message("cancel-user-input/uid", function(uid) + cancel_input_request(function(i) + return queue[i].response == uid + end) +end) + +-- removes all requests with the specified id from the queue +mp.register_script_message("cancel-user-input/id", function(id) + cancel_input_request(function(i) + return queue[i].id == id + end) +end) + +-- ensures a request has the correct fields and is correctly formatted +local function format_request_fields(req) + assert(req.version, "input requests require an API version string") + if not string.find(req.version, API_MAJOR_MINOR, 1, true) then + error(("input request has invalid version: expected %s.x, got %s"):format(API_MAJOR_MINOR, req.version)) + end + + assert(req.response, "input requests require a response string") + assert(req.id, "input requests require an id string") + + req.text = ass_escape(req.request_text or "") + req.default_input = req.default_input or "" + req.cursor_pos = tonumber(req.cursor_pos) or 1 + req.id = req.id or "mpv" + + if req.cursor_pos ~= 1 then + if req.cursor_pos < 1 then + req.cursor_pos = 1 + elseif req.cursor_pos > #req.default_input then + req.cursor_pos = #req.default_input + 1 + end + end + + if not histories[req.id] then + histories[req.id] = { pos = 1, list = {} } + end + req.history = histories[req.id] + return req +end + +-- updates the fields of a specific request +mp.register_script_message("update-user-input/uid", function(uid, req_opts) + req_opts = utils.parse_json(req_opts) + req_opts.response = uid + for i, req in ipairs(queue) do + if req.response == uid then + local success, result = pcall(format_request_fields, req_opts) + if not success then + return msg.error(result) + end + + queue[i] = result + if i == 1 then + request = queue[1] + end + update() + return + end + end +end) + +--the function that parses the input requests +local function input_request(req) + req = format_request_fields(req) + push_request(req) +end + +-- script message to recieve input requests, get-user-input.lua acts as an interface to call this script message +mp.register_script_message("request-user-input", function(req) + msg.debug(req) + req = utils.parse_json(req) + local success, err = pcall(input_request, req) + if not success then + send_response({ err = err, response = req.response, source = req.source }) + msg.error(err) + end +end) diff --git a/mac/.config/mpv/scripts/xscreensaver.lua b/mac/.config/mpv/scripts/xscreensaver.lua new file mode 100644 index 0000000..a54b944 --- /dev/null +++ b/mac/.config/mpv/scripts/xscreensaver.lua @@ -0,0 +1,24 @@ +-- this script periodically deactivates xscreensaver +-- when video playback is active + +local function heartbeat() + if + mp.get_property_native("pause") + or mp.get_property_native("idle") + or not mp.get_property_native("vo-configured") + then + return + end + + mp.command_native_async({ + name = "subprocess", + args = { "xscreensaver-command", "-deactivate" }, + capture_stdout = true, + }, function() end) +end + +mp.add_periodic_timer(60, heartbeat) + +for _, prop in ipairs({ "pause", "idle", "vo-configured" }) do + mp.observe_property(prop, nil, heartbeat) +end diff --git a/mac/.config/mpv/scripts/youtube-search.lua b/mac/.config/mpv/scripts/youtube-search.lua new file mode 100644 index 0000000..8989447 --- /dev/null +++ b/mac/.config/mpv/scripts/youtube-search.lua @@ -0,0 +1,419 @@ +--[[ + This script allows users to search and open youtube results from within mpv. + Available at: https://github.com/CogentRedTester/mpv-scripts + + Users can open the search page with Y, and use Y again to open a search. + Alternatively, Ctrl+y can be used at any time to open a search. + Esc can be used to close the page. + Enter will open the selected item, Shift+Enter will append the item to the playlist. + + This script requires that my other scripts `scroll-list` and `user-input` be installed. + scroll-list.lua and user-input-module.lua must be in the ~~/script-modules/ directory, + while user-input.lua should be loaded by mpv normally. + + https://github.com/CogentRedTester/mpv-scroll-list + https://github.com/CogentRedTester/mpv-user-input + + This script also requires a youtube API key to be entered. + The API key must be passed to the `API_key` script-opt. + A personal API key is free and can be created from: + https://console.developers.google.com/apis/api/youtube.googleapis.com/ + + The script also requires that curl be in the system path. + + An alternative to using the official youtube API is to use Invidious. + This script has experimental support for Invidious searches using the 'invidious', + 'API_path', and 'frontend' options. API_path refers to the url of the API the + script uses, Invidious API paths are usually in the form: + https://domain.name/api/v1/ + The frontend option is the url to actualy try to load videos from. This + can probably be the same as the above url: + https://domain.name + Since the url syntax seems to be identical between Youtube and Invidious, + it should be possible to mix these options, a.k.a. using the Google + API to get videos from an Invidious frontend, or to use an Invidious + API to get videos from Youtube. + The 'invidious' option tells the script that the API_path is for an + Invidious path. This is to support other possible API options in the future. +]] +-- + +local mp = require("mp") +local msg = require("mp.msg") +local utils = require("mp.utils") +local opts = require("mp.options") + +package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path +local ui = require("user-input-module") +local list = require("scroll-list") + +local o = { + API_key = io.popen("pass show api/google-cloud/youtube-search"):read("*a"):gsub("%s+", ""), + + --number of search results to show in the list + num_results = 40, + + --the url to send API calls to + API_path = "https://www.googleapis.com/youtube/v3/", + + --attempt this API if the default fails + fallback_API_path = "", + + --the url to load videos from + frontend = "https://www.youtube.com", + + --use invidious API calls + invidious = false, + + --whether the fallback uses invidious as well + fallback_invidious = false, +} + +opts.read_options(o) + +--ensure the URL options are properly formatted +local function format_options() + if o.API_path:sub(-1) ~= "/" then + o.API_path = o.API_path .. "/" + end + if o.fallback_API_path:sub(-1) ~= "/" then + o.fallback_API_path = o.fallback_API_path .. "/" + end + if o.frontend:sub(-1) == "/" then + o.frontend = o.frontend:sub(1, -2) + end +end + +format_options() + +list.header = ("%s Search: \\N-------------------------------------------------"):format( + o.invidious and "Invidious" or "Youtube" +) +list.num_entries = 17 +list.list_style = [[{\fs10}\N{\q2\fs25\c&Hffffff&}]] +list.empty_text = "enter search query" + +local ass_escape = list.ass_escape + +--encodes a string so that it uses url percent encoding +--this function is based on code taken from here: https://rosettacode.org/wiki/URL_encoding#Lua +local function encode_string(str) + if type(str) ~= "string" then + return str + end + local output, t = str:gsub("[^%w]", function(char) + return string.format("%%%X", string.byte(char)) + end) + return output +end + +--convert HTML character codes to the correct characters +local function html_decode(str) + if type(str) ~= "string" then + return str + end + + return str:gsub("&(#?)(%w-);", function(is_ascii, code) + if is_ascii == "#" then + return string.char(tonumber(code)) + end + if code == "amp" then + return "&" + end + if code == "quot" then + return '"' + end + if code == "apos" then + return "'" + end + if code == "lt" then + return "<" + end + if code == "gt" then + return ">" + end + return nil + end) +end + +--creates a formatted results table from an invidious API call +local function format_invidious_results(response) + if not response then + return nil + end + local results = {} + + for i, item in ipairs(response) do + if i > o.num_results then + break + end + + local t = {} + table.insert(results, t) + + t.title = html_decode(item.title) + t.channelTitle = html_decode(item.author) + if item.type == "video" then + t.type = "video" + t.id = item.videoId + elseif item.type == "playlist" then + t.type = "playlist" + t.id = item.playlistId + elseif item.type == "channel" then + t.type = "channel" + t.id = item.authorId + t.title = t.channelTitle + end + end + + return results +end + +--creates a formatted results table from a youtube API call +function format_youtube_results(response) + if not response or not response.items then + return nil + end + local results = {} + + for _, item in ipairs(response.items) do + local t = {} + table.insert(results, t) + + t.title = html_decode(item.snippet.title) + t.channelTitle = html_decode(item.snippet.channelTitle) + + if item.id.kind == "youtube#video" then + t.type = "video" + t.id = item.id.videoId + elseif item.id.kind == "youtube#playlist" then + t.type = "playlist" + t.id = item.id.playlistId + elseif item.id.kind == "youtube#channel" then + t.type = "channel" + t.id = item.id.channelId + end + end + + return results +end + +--sends an API request +local function send_request(type, queries, API_path) + local url = (API_path or o.API_path) .. type + url = url .. "?" + + for key, value in pairs(queries) do + msg.verbose(key, value) + url = url .. "&" .. key .. "=" .. encode_string(value) + end + + msg.debug(url) + local request = mp.command_native({ + name = "subprocess", + capture_stdout = true, + capture_stderr = true, + playback_only = false, + args = { "curl", url }, + }) + + local response = utils.parse_json(request.stdout) + msg.trace(utils.to_string(request)) + + if request.status ~= 0 then + msg.error(request.stderr) + return nil + end + if not response then + msg.error("Could not parse response:") + msg.error(request.stdout) + return nil + end + if response.error then + msg.error(request.stdout) + return nil + end + + return response +end + +--sends a search API request - handles Google/Invidious API differences +local function search_request(queries, API_path, invidious) + list.header = ("%s Search: %s\\N-------------------------------------------------"):format( + invidious and "Invidious" or "Youtube", + ass_escape(queries.q, true) + ) + list.list = {} + list.empty_text = "~" + list:update() + local results = {} + + --we need to modify the returned results so that the rest of the script can read it + if invidious then + --Invidious searches are done with pages rather than a max result number + local page = 1 + while #results < o.num_results do + queries.page = page + + local response = send_request("search", queries, API_path) + response = format_invidious_results(response) + if not response then + msg.warn("Search did not return a results list") + return + end + if #response == 0 then + break + end + + for _, item in ipairs(response) do + table.insert(results, item) + end + + page = page + 1 + end + else + local response = send_request("search", queries, API_path) + results = format_youtube_results(response) + end + + --print error messages to console if the API request fails + if not results then + msg.warn("Search did not return a results list") + return + end + + list.empty_text = "no results" + return results +end + +local function insert_video(item) + list:insert({ + ass = ("%s {\\c&aaaaaa&}%s"):format(ass_escape(item.title), ass_escape(item.channelTitle)), + url = ("%s/watch?v=%s"):format(o.frontend, item.id), + }) +end + +local function insert_playlist(item) + list:insert({ + ass = ("🖿 %s {\\c&aaaaaa&}%s"):format(ass_escape(item.title), ass_escape(item.channelTitle)), + url = ("%s/playlist?list=%s"):format(o.frontend, item.id), + }) +end + +local function insert_channel(item) + list:insert({ + ass = ("👤 %s"):format(ass_escape(item.title)), + url = ("%s/channel/%s"):format(o.frontend, item.id), + }) +end + +local function reset_list() + list.selected = 1 + list:clear() +end + +--creates the search request queries depending on what API we're using +local function get_search_queries(query, invidious) + if invidious then + return { + q = query, + type = "all", + page = 1, + } + else + return { + key = o.API_key, + q = query, + part = "id,snippet", + maxResults = o.num_results, + } + end +end + +local function search(query) + local response = search_request(get_search_queries(query, o.invidious), o.API_path, o.invidious) + if not response and o.fallback_API_path ~= "/" then + msg.info("search failed - attempting fallback") + response = + search_request(get_search_queries(query, o.fallback_invidious), o.fallback_API_path, o.fallback_invidious) + end + + if not response then + return + end + reset_list() + + for _, item in ipairs(response) do + if item.type == "video" then + insert_video(item) + elseif item.type == "playlist" then + insert_playlist(item) + elseif item.type == "channel" then + insert_channel(item) + end + end + list:update() + list:open() +end + +local function play_result(flag) + if not list[list.selected] then + return + end + if flag == "new_window" then + mp.commandv("run", "mpv", list[list.selected].url) + return + end + + mp.commandv("loadfile", list[list.selected].url, flag) + if flag == "replace" then + list:close() + end +end + +table.insert(list.keybinds, { + "ENTER", + "play", + function() + play_result("replace") + end, + {}, +}) +table.insert(list.keybinds, { + "Shift+ENTER", + "play_append", + function() + play_result("append-play") + end, + {}, +}) +table.insert(list.keybinds, { + "Ctrl+ENTER", + "play_new_window", + function() + play_result("new_window") + end, + {}, +}) + +local function open_search_input() + ui.get_user_input(function(input) + if not input then + return + end + search(input) + end, { request_text = "Enter Query:" }) +end + +mp.add_key_binding("", "yt", open_search_input) + +mp.add_key_binding("", "youtube-search", function() + if not list.hidden then + open_search_input() + else + list:open() + if #list.list == 0 then + open_search_input() + end + end +end) diff --git a/mac/.config/mpv/scripts/ytdl-preload.lua b/mac/.config/mpv/scripts/ytdl-preload.lua new file mode 100644 index 0000000..835b25c --- /dev/null +++ b/mac/.config/mpv/scripts/ytdl-preload.lua @@ -0,0 +1,433 @@ +---------------------- +-- #example ytdl_preload.conf +-- # make sure lines do not have trailing whitespace +-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace +-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider + +-- #temp=R:\ytdltest +-- #ytdl_opt1=-r 50k +-- #ytdl_opt2=-N 5 +-- #ytdl_opt#=etc +---------------------- +local nextIndex +local caught = true +-- local pop = false +local ytdl = "yt-dlp" +local utils = require("mp.utils") +local options = require("mp.options") +local cache = os.getenv("XDG_CACHE_HOME") +local opts = { + temp = cache .. "/mpv", + ytdl_opt1 = "", + ytdl_opt2 = "", + ytdl_opt3 = "", + ytdl_opt4 = "", + ytdl_opt5 = "", + ytdl_opt6 = "", + ytdl_opt7 = "", + ytdl_opt8 = "", + ytdl_opt9 = "", +} +options.read_options(opts, "ytdl_preload") +local additionalOpts = {} +for k, v in pairs(opts) do + if k:find("ytdl_opt%d") and v ~= "" then + additionalOpts[k] = v + -- print("entry") + -- print(k .. v) + end +end +local cachePath = opts.temp + +local chapter_list = {} +local json = "" +local filesToDelete = {} + +local function exists(file) + local ok, err, code = os.rename(file, file) + if not ok then + if code == 13 then -- Permission denied, but it exists + return true + end + end + return ok, err +end +--from ytdl_hook +local function time_to_secs(time_string) + local ret + local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)") + if a ~= nil then + ret = (a * 3600 + b * 60 + c) + else + a, b = time_string:match("(%d%d?):(%d%d)") + if a ~= nil then + ret = (a * 60 + b) + end + end + return ret +end +local function extract_chapters(data, video_length) + local ret = {} + for line in data:gmatch("[^\r\n]+") do + local time = time_to_secs(line) + if time and (time < video_length) then + table.insert(ret, { time = time, title = line }) + end + end + table.sort(ret, function(a, b) + return a.time < b.time + end) + return ret +end +local function chapters() + if json.chapters then + for i = 1, #json.chapters do + local chapter = json.chapters[i] + local title = chapter.title or "" + if title == "" then + title = string.format("Chapter %02d", i) + end + table.insert(chapter_list, { time = chapter.start_time, title = title }) + end + elseif not (json.description == nil) and not (json.duration == nil) then + chapter_list = extract_chapters(json.description, json.duration) + end +end +--end ytdl_hook +local title = "" +local fVideo = "" +local fAudio = "" +local function load_files(dtitle, destination, audio, wait) + if wait then + if exists(destination .. ".mka") then + print("---wait success: found mka---") + audio = "audio-file=" .. destination .. ".mka," + else + print("---could not find mka after wait, audio may be missing---") + end + end + -- if audio ~= "" then + -- table.insert(filesToDelete, destination .. ".mka") + -- end + -- table.insert(filesToDelete, destination .. ".mkv") + dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "") + dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "") + mp.commandv( + "loadfile", + destination .. ".mkv", + "append", + audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no' + ) --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload + mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex) + mp.commandv("playlist_remove", nextIndex + 1) + caught = true + title = "" + -- pop = true +end + +local listenID = "" +local function listener(event) + if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then + local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") + or string.match(event.text, "%[download%] (.+).mkv has already been downloaded") + -- if destination then print("---"..cachePath) end; + if destination and string.find(destination, string.gsub(cachePath, "~/", "")) then + -- print(listenID) + mp.unregister_event(listener) + _, title = utils.split_path(destination) + local audio = "" + if fAudio == "" then + load_files(title, destination, audio, false) + else + if exists(destination .. ".mka") then + audio = "audio-file=" .. destination .. ".mka," + load_files(title, destination, audio, false) + else + print("---expected mka but could not find it, waiting for 2 seconds---") + mp.add_timeout(2, function() + load_files(title, destination, audio, true) + end) + end + end + end + end +end + +--from ytdl_hook +mp.add_hook("on_preloaded", 10, function() + if string.find(mp.get_property("path"), cachePath) then + chapters() + if next(chapter_list) ~= nil then + mp.set_property_native("chapter-list", chapter_list) + chapter_list = {} + json = "" + end + end +end) +--end ytdl_hook +function dump(o) + if type(o) == "table" then + local s = "{ " + for k, v in pairs(o) do + if type(k) ~= "number" then + k = '"' .. k .. '"' + end + s = s .. "[" .. k .. "] = " .. dump(v) .. "," + end + return s .. "} " + else + return tostring(o) + end +end + +local function addOPTS(old) + for k, v in pairs(additionalOpts) do + -- print(k) + if string.find(v, "%s") then + for l, w in string.gmatch(v, "([-%w]+) (.+)") do + table.insert(old, l) + table.insert(old, w) + end + else + table.insert(old, v) + end + end + -- print(dump(old)) + return old +end + +local AudioDownloadHandle = {} +local VideoDownloadHandle = {} +local JsonDownloadHandle = {} +local function download_files(id, success, result, error) + if result.killed_by_us then + return + end + local jfile = cachePath .. "/" .. id .. ".json" + + local jfileIO = io.open(jfile, "w") + jfileIO:write(result.stdout) + jfileIO:close() + json = utils.parse_json(result.stdout) + -- print(dump(json)) + if json.requested_downloads[1].requested_formats ~= nil then + local args = { + ytdl, + "--no-continue", + "-q", + "-f", + fAudio, + "--restrict-filenames", + "--no-playlist", + "--no-part", + "-o", + cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", + "--load-info-json", + jfile, + } + args = addOPTS(args) + AudioDownloadHandle = mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + }, function() end) + else + fAudio = "" + fVideo = fVideo:gsub("bestvideo", "best") + fVideo = fVideo:gsub("bv", "best") + end + + local args = { + ytdl, + "--no-continue", + "-f", + fVideo .. "/best", + "--restrict-filenames", + "--no-playlist", + "--no-part", + "-o", + cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", + "--load-info-json", + jfile, + } + args = addOPTS(args) + VideoDownloadHandle = mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + }, function() end) +end + +local function DL() + local index = tonumber(mp.get_property("playlist-pos")) + if + mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") + and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") + then + return + end + if + tonumber(mp.get_property("playlist-pos-1")) > 0 + and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") + then + nextIndex = index + 1 + local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename") + if nextFile and caught and nextFile:find("://", 0, false) then + caught = false + mp.enable_messages("info") + mp.register_event("log-message", listener) + local ytFormat = mp.get_property("ytdl-format") + fVideo = string.match(ytFormat, "(.+)%+.+//?") or "bestvideo" + fAudio = string.match(ytFormat, ".+%+(.+)//?") or "bestaudio" + -- print("start"..nextFile) + listenID = tostring(os.time()) + local args = { + ytdl, + "--dump-single-json", + "--no-simulate", + "--skip-download", + "--restrict-filenames", + "--no-playlist", + "--sub-lang", + "en", + "--write-sub", + "--no-part", + "-o", + cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", + nextFile, + } + args = addOPTS(args) + -- print(dump(args)) + table.insert(filesToDelete, listenID) + JsonDownloadHandle = mp.command_native_async({ + name = "subprocess", + args = args, + capture_stdout = true, + capture_stderr = true, + playback_only = false, + }, function(...) + download_files(listenID, ...) + end) + end + end +end + +local function clearCache() + -- print(pop) + + --if pop == true then + mp.abort_async_command(AudioDownloadHandle) + mp.abort_async_command(VideoDownloadHandle) + mp.abort_async_command(JsonDownloadHandle) + -- for k, v in pairs(filesToDelete) do + -- print("remove: " .. v) + -- os.remove(v) + -- end + local ftd = io.open(cachePath .. "/temp.files", "a") + for k, v in pairs(filesToDelete) do + ftd:write(v .. "\n") + if package.config:sub(1, 1) ~= "/" then + os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"') + else + os.execute("rm -f " .. cachePath .. "/" .. v .. "*") + end + end + ftd:close() + print("clear") + mp.command("quit") + --end +end +mp.add_hook("on_unload", 50, function() + -- mp.abort_async_command(AudioDownloadHandle) + -- mp.abort_async_command(VideoDownloadHandle) + mp.abort_async_command(JsonDownloadHandle) + mp.unregister_event(listener) + caught = true + listenID = "resetYtdlPreloadListener" + -- print(listenID) +end) + +local skipInitial +mp.observe_property("playlist-count", "number", function() + if skipInitial then + DL() + else + skipInitial = true + end +end) + +--from ytdl_hook +local platform_is_windows = (package.config:sub(1, 1) == "\\") +local o = { + exclude = "", + try_ytdl_first = false, + use_manifests = false, + all_formats = false, + force_all_formats = true, + ytdl_path = "", +} +local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" } +--local options = require 'mp.options' +options.read_options(o, "ytdl_hook") + +local separator = platform_is_windows and ";" or ":" +if o.ytdl_path:match("[^" .. separator .. "]") then + paths_to_search = {} + for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do + table.insert(paths_to_search, path) + end +end + +local function exec(args) + local ret = mp.command_native({ + name = "subprocess", + args = args, + capture_stdout = true, + capture_stderr = true, + }) + return ret.status, ret.stdout, ret, ret.killed_by_us +end + +local msg = require("mp.msg") +local command = {} +for _, path in pairs(paths_to_search) do + -- search for youtube-dl in mpv's config dir + local exesuf = platform_is_windows and ".exe" or "" + local ytdl_cmd = mp.find_config_file(path .. exesuf) + if ytdl_cmd then + msg.verbose("Found youtube-dl at: " .. ytdl_cmd) + ytdl = ytdl_cmd + break + else + msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories") + --search in PATH + command[1] = path + es, json, result, aborted = exec(command) + if result.error_string == "init" then + msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions") + else + msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH") + ytdl = path + break + end + end +end +--end ytdl_hook + +mp.register_event("start-file", DL) +mp.register_event("shutdown", clearCache) +local ftd = io.open(cachePath .. "/temp.files", "r") +while ftd ~= nil do + local line = ftd:read() + if line == nil or line == "" then + ftd:close() + io.open(cachePath .. "/temp.files", "w"):close() + break + end + -- print("DEL::"..line) + if package.config:sub(1, 1) ~= "/" then + os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul') + else + os.execute("rm -f " .. cachePath .. "/" .. line .. "* &> /dev/null") + end +end diff --git a/mac/.config/mpv/unused_scipts/xrandr.lua b/mac/.config/mpv/unused_scipts/xrandr.lua new file mode 100644 index 0000000..43ce59f --- /dev/null +++ b/mac/.config/mpv/unused_scipts/xrandr.lua @@ -0,0 +1,382 @@ +-- use xrandr command to set output to best fitting fps rate +-- when playing videos with mpv. + +utils = require 'mp.utils' + +-- if you want your display output switched to a certain mode during playback, +-- use e.g. "--script-opts=xrandr-output-mode=1920x1080" +xrandr_output_mode = mp.get_opt("xrandr-output-mode") + +xrandr_blacklist = {} +function xrandr_parse_blacklist() + -- use e.g. "--script-opts=xrandr-blacklist=25" to have xrand.lua not use 25Hz refresh rate + + -- Parse the optional "blacklist" from a string into an array for later use. + -- For now, we only support a list of rates, since the "mode" is not subject + -- to automatic change (mpv is better at scaling than most displays) and + -- this also makes the blacklist option more easy to specify: + local b = mp.get_opt("xrandr-blacklist") + if (b == nil) then + return + end + + local i = 1 + for s in string.gmatch(b, "([^, ]+)") do + xrandr_blacklist[i] = 0.0 + s + i = i+1 + end +end +xrandr_parse_blacklist() + + +function xrandr_check_blacklist(mode, rate) + -- check if (mode, rate) is black-listed - e.g. because the + -- computer display output is known to be incompatible with the + -- display at this specific mode/rate + + for i=1,#xrandr_blacklist do + r = xrandr_blacklist[i] + + if (r == rate) then + mp.msg.log("v", "will not use mode '" .. mode .. "' with rate " .. rate .. " because option --script-opts=xrandr-blacklist said so") + return true + end + end + + return false +end + +xrandr_detect_done = false +xrandr_modes = {} +xrandr_connected_outputs = {} +function xrandr_detect_available_rates() + if (xrandr_detect_done) then + return + end + xrandr_detect_done = true + + -- invoke xrandr to find out which fps rates are available on which outputs + + local p = {} + p["cancellable"] = false + p["args"] = {} + p["args"][1] = "xrandr" + p["args"][2] = "-q" + local res = utils.subprocess(p) + + if (res["error"] ~= nil) then + mp.msg.log("info", "failed to execute 'xrand -q', error message: " .. res["error"]) + return + end + + mp.msg.log("v","xrandr -q\n" .. res["stdout"]) + + local output_idx = 1 + for output in string.gmatch(res["stdout"], '\n([^ ]+) connected') do + + table.insert(xrandr_connected_outputs, output) + + -- the first line with a "*" after the match contains the rates associated with the current mode + local mls = string.match(res["stdout"], "\n" .. string.gsub(output, "%p", "%%%1") .. " connected.*") + local r + local mode = nil + local old_rate + local old_mode + + -- old_rate = 0 means "no old rate known to switch to after playback" + old_rate = 0 + + if (xrandr_output_mode ~= nil) then + -- special case: user specified a certain preferred mode to use for playback + mp.msg.log("v", "looking for refresh rates for user supplied output mode " .. xrandr_output_mode) + mode, r = string.match(mls, '\n (' .. xrandr_output_mode .. ') ([^\n]+)') + + if (mode == nil) then + mp.msg.log("info", "user preferred output mode " .. xrandr_output_mode .. " not found for output " .. output .. " - will use current mode") + else + mp.msg.log("info", "using user preferred xrandr_output_mode " .. xrandr_output_mode .. " for output " .. output) + -- try to find the "old rate" for the other, currently active mode + local oldr + old_mode, oldr = string.match(mls, '\n ([0-9x]+) ([^*\n]*%*[^\n]*)') + if (oldr ~= nil) then + for s in string.gmatch(oldr, "([^ ]+)%*") do + old_rate = s + end + end + mp.msg.log("v", "old_rate=" .. old_rate .. " found for old_mode=" .. tostring(old_mode)) + end + end + + if (mode == nil) then + -- normal case: use current mode + mode, r = string.match(mls, '\n ([0-9x]+) ([^*\n]*%*[^\n]*)') + old_mode = mode + end + + if (r == nil) then + -- if no refresh rate is reported active for an output by xrandr, + -- search for the mode that is "recommended" (marked by "+" in xrandr's output) + mode, r = string.match(mls, '\n ([0-9x]+) ([^+\n]*%+[^\n]*)') + old_mode = mode + if (r == nil) then + -- there is not even a "recommended" mode, so let's just use + -- whatever first mode line there is + mode, r = string.match(mls, '\n ([0-9x]+) ([^+\n]*[^\n]*)') + old_mode = mode + end + else + -- so "r" contains a hint to the current ("old") rate, let's remember + -- it for later switching back to it. + for s in string.gmatch(r, "([^ ]+)%*") do + old_rate = s + end + end + mp.msg.log("info", "output " .. output .. " mode=" .. mode .. " old rate=" .. old_rate .. " refresh rates = " .. r) + + xrandr_modes[output] = { mode = mode, old_mode = old_mode, rates_s = r, rates = {}, old_rate = old_rate } + local i = 1 + for s in string.gmatch(r, "([^ +*]+)") do + + -- check if rate "r" is black-listed - this is checked here because + if (not xrandr_check_blacklist(mode, 0.0 + s)) then + xrandr_modes[output].rates[i] = 0.0 + s + i = i+1 + end + end + + output_idx = output_idx + 1 + end + +end + +function xrandr_find_best_fitting_rate(fps, output) + + local xrandr_rates = xrandr_modes[output].rates + + local best_fitting_rate = nil + local best_fitting_ratio = math.huge + + -- try integer multipliers of 1 to 10 (given that high-fps displays exist these days) + for m=1,10 do + for i=1,#xrandr_rates do + local r = xrandr_rates[i] + local ratio = r / (m * fps) + if (ratio < 1.0) then + ratio = 1.0 / ratio + end + -- If the ratio is more than "very insignificantly off", + -- then add a tiny additional score that will prefer faster + -- over slower display frame rates, because those will cause + -- shorter "stutters" when the display needs to skip or + -- duplicate one source frame. + -- If the ratio is very close to 1.0, then we rather not + -- choose the higher of the existing display rates, because + -- displays performing frame interpolation work better when + -- presented the actual, non-repeated source material frames. + if (ratio > 1.0001) then + ratio = ratio + (0.00000001 * (1000.0 - r)) + end + -- mp.msg.log("info", "ratio " .. ratio .. " for r == " .. r) + if (ratio < best_fitting_ratio) then + best_fitting_ratio = ratio + -- the xrand -q output may print nearby frequencies as the same + -- rounded numbers - therefore, if our multiplier is == 1, + -- we better return the video's frame rate, which xrandr + -- is then likely to set the best rate for, even if the mode + -- has some "odd" rate + if (m == 1) then + r = fps + end + best_fitting_rate = r + end + end + end + + return best_fitting_rate +end + + +xrandr_active_outputs = {} +function xrandr_set_active_outputs() + local dn = mp.get_property("display-names") + + if (dn ~= nil) then + mp.msg.log("v","display-names=" .. dn) + xrandr_active_outputs = {} + for w in (dn .. ","):gmatch("([^,]*),") do + table.insert(xrandr_active_outputs, w) + end + end +end + +-- last detected non-nil video frame rate: +xrandr_cfps = nil + +-- for each output, we remember which refresh rate we set last, so +-- we do not unnecessarily set the same refresh rate again +xrandr_previously_set = {} + +function xrandr_set_rate() + + local f = mp.get_property_native("container-fps") + if (f == nil or f == xrandr_cfps) then + -- either no change or no frame rate information, so don't set anything + return + end + xrandr_cfps = f + + xrandr_detect_available_rates() + + xrandr_set_active_outputs() + + local vdpau_hack = false + local old_vid = nil + local old_position = nil + if (mp.get_property("options/vo") == "vdpau" or mp.get_property("options/hwdec") == "vdpau") then + -- enable wild hack: need to close and re-open video for vdpau, + -- because vdpau barfs if xrandr is run while it is in use + + vdpau_hack = true + old_position = mp.get_property("time-pos") + old_vid = mp.get_property("vid") + mp.set_property("vid", "no") + end + + -- unless "--script-opts=xrandr-ignore_unknown_oldrate=true" is set, + -- xrandr.lua will not touch display outputs for which it cannot + -- get information on the current refresh rate for - assuming that + -- such outputs are "disabled" somehow. + local ignore_unknown_oldrate = mp.get_opt("xrandr-ignore_unknown_oldrate") + if (ignore_unknown_oldrate == nil) then + ignore_unknown_oldrate = false + end + + + local outs = {} + if (#xrandr_active_outputs == 0) then + -- No active outputs - probably because vo (like with vdpau) does + -- not provide the information which outputs are covered. + -- As a fall-back, let's assume all connected outputs are relevant. + mp.msg.log("v","no output is known to be used by mpv, assuming all connected outputs are used.") + outs = xrandr_connected_outputs + else + outs = xrandr_active_outputs + end + + -- iterate over all relevant outputs used by mpv's output: + for n, output in ipairs(outs) do + + if (ignore_unknown_oldrate == false and xrandr_modes[output].old_rate == 0) then + mp.msg.log("info", "not touching output " .. output .. " because xrandr did not indicate a used refresh rate for it - use --script-opts=xrandr-ignore_unknown_oldrate=true if that is not what you want.") + else + local bfr = xrandr_find_best_fitting_rate(xrandr_cfps, output) + + if (bfr == 0.0) then + mp.msg.log("info", "no non-blacklisted rate available, not invoking xrandr") + else + mp.msg.log("info", "container fps is " .. xrandr_cfps .. "Hz, for output " .. output .. " mode " .. xrandr_modes[output].mode .. " the best fitting display rate we will pass to xrandr is " .. bfr .. "Hz") + + if (bfr == xrandr_previously_set[output]) then + mp.msg.log("v", "output " .. output .. " was already set to " .. bfr .. "Hz before - not changing") + else + -- invoke xrandr to set the best fitting refresh rate for output + local p = {} + p["cancellable"] = false + p["args"] = {} + p["args"][1] = "xrandr" + p["args"][2] = "--output" + p["args"][3] = output + p["args"][4] = "--mode" + p["args"][5] = xrandr_modes[output].mode + p["args"][6] = "--rate" + p["args"][7] = tostring(bfr) + + local cmd_as_string = "" + for k, v in pairs(p["args"]) do + cmd_as_string = cmd_as_string .. v .. " " + end + mp.msg.log("debug", "executing as subprocess: \"" .. cmd_as_string .. "\"") + local res = utils.subprocess(p) + + if (res["error"] ~= nil) then + mp.msg.log("error", "failed to set refresh rate for output " .. output .. " using xrandr, error message: " .. res["error"]) + else + xrandr_previously_set[output] = bfr + end + end + end + end + end + + if (vdpau_hack) then + mp.set_property("vid", old_vid) + if (old_position ~= nil) then + mp.commandv("seek", old_position, "absolute", "keyframes") + else + mp.msg.log("v", "old_position is 'nil' - not seeking after vdpau re-initialization") + end + end +end + + +function xrandr_set_old_rate() + + local outs = {} + if (#xrandr_active_outputs == 0) then + -- No active outputs - probably because vo (like with vdpau) does + -- not provide the information which outputs are covered. + -- As a fall-back, let's assume all connected outputs are relevant. + mp.msg.log("v","no output is known to be used by mpv, assuming all connected outputs are used.") + outs = xrandr_connected_outputs + else + outs = xrandr_active_outputs + end + + -- iterate over all relevant outputs used by mpv's output: + for n, output in ipairs(outs) do + + local old_rate = xrandr_modes[output].old_rate + + if (old_rate == 0 or xrandr_previously_set[output] == nil ) then + mp.msg.log("v", "no previous frame rate known for output " .. output .. " - so no switching back.") + else + + if (math.abs(old_rate-xrandr_previously_set[output]) < 0.001) then + mp.msg.log("v", "output " .. output .. " is already set to " .. old_rate .. "Hz - no switching back required") + else + + mp.msg.log("info", "switching output " .. output .. " that was set for replay to mode " .. xrandr_modes[output].mode .. " at " .. xrandr_previously_set[output] .. "Hz back to mode " .. xrandr_modes[output].old_mode .. " with refresh rate " .. old_rate .. "Hz") + + -- invoke xrandr to set the best fitting refresh rate for output + local p = {} + p["cancellable"] = false + p["args"] = {} + p["args"][1] = "xrandr" + p["args"][2] = "--output" + p["args"][3] = output + p["args"][4] = "--mode" + p["args"][5] = xrandr_modes[output].old_mode + p["args"][6] = "--rate" + p["args"][7] = old_rate + + local res = utils.subprocess(p) + + if (res["error"] ~= nil) then + mp.msg.log("error", "failed to set refresh rate for output " .. output .. " using xrandr, error message: " .. res["error"]) + else + xrandr_previously_set[output] = old_rate + end + end + end + + end + +end + +-- we'll consider setting refresh rates whenever the video fps or the active outputs change: +mp.observe_property("container-fps", "native", xrandr_set_rate) +mp.observe_property("display-names", "native", xrandr_set_rate) + +-- and we'll try to revert the refresh rate when mpv is shut down +mp.register_event("shutdown", xrandr_set_old_rate) + |
