diff --git a/xfce-custom/dots/home/neko/.config/dconf/user b/xfce-custom/dots/home/neko/.config/dconf/user new file mode 100644 index 0000000..fa97616 Binary files /dev/null and b/xfce-custom/dots/home/neko/.config/dconf/user differ diff --git a/xfce-custom/dots/home/neko/.config/mimeapps.list b/xfce-custom/dots/home/neko/.config/mimeapps.list new file mode 100644 index 0000000..5dfebc1 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mimeapps.list @@ -0,0 +1,14 @@ +[Default Applications] +x-scheme-handler/http=xfce4-web-browser.desktop +x-scheme-handler/https=xfce4-web-browser.desktop +image/jpeg=sxiv.desktop +image/png=sxiv.desktop +image/gif=sxiv.desktop + +[Added Associations] +x-scheme-handler/http=xfce4-web-browser.desktop; +x-scheme-handler/https=xfce4-web-browser.desktop; +image/jpeg=librewolf.desktop;sxiv.desktop; +image/png=sxiv.desktop; +image/gif=sxiv.desktop; +application/x-desktop=librewolf.desktop; diff --git a/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_icons.otf b/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_icons.otf new file mode 100644 index 0000000..4c4e0dc Binary files /dev/null and b/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_icons.otf differ diff --git a/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_textures.ttf b/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_textures.ttf new file mode 100644 index 0000000..e89f1d8 Binary files /dev/null and b/xfce-custom/dots/home/neko/.config/mpv/fonts/uosc_textures.ttf differ diff --git a/xfce-custom/dots/home/neko/.config/mpv/input.conf b/xfce-custom/dots/home/neko/.config/mpv/input.conf new file mode 100644 index 0000000..8918ea3 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/input.conf @@ -0,0 +1,10 @@ +F1 script-message-to command_palette show-command-palette bindings # Show bindings +F2 script-message-to command_palette show-command-palette commands # Show commands +F3 script-message-to command_palette show-command-palette properties # Show properties +F4 script-message-to command_palette show-command-palette options # Show options +F8 script-message-to command_palette show-command-palette playlist # Show playlist +Alt+c script-message-to command_palette show-command-palette chapters # Show chapters +Alt+a script-message-to command_palette show-command-palette audio # Show audio tracks +Alt+s script-message-to command_palette show-command-palette subtitle # Show subtitle tracks +Alt+v script-message-to command_palette show-command-palette video # Show video tracks +Alt+p script-message-to command_palette show-command-palette profiles # Show profiles diff --git a/xfce-custom/dots/home/neko/.config/mpv/mpv.conf b/xfce-custom/dots/home/neko/.config/mpv/mpv.conf new file mode 100644 index 0000000..6de9e33 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/mpv.conf @@ -0,0 +1,7 @@ +# required so that the 2 UIs don't fight each other +osc=no +# uosc provides its own seeking/volume indicators, so you also don't need this +osd-bar=no +# uosc will draw its own window controls if you disable window border +border=yes +profile=pseudo-gui diff --git a/xfce-custom/dots/home/neko/.config/mpv/script-modules/extended-menu.lua b/xfce-custom/dots/home/neko/.config/mpv/script-modules/extended-menu.lua new file mode 100644 index 0000000..04c6f25 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/script-modules/extended-menu.lua @@ -0,0 +1,897 @@ +local mp = require 'mp' +local utils = require 'mp.utils' +local assdraw = require 'mp.assdraw' + +-- create namespace with default values +local em = { + + -- customisable values ------------------------------------------------------ + + 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, + -- 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 + 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.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 + 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 ww, wh = mp.get_osd_size() -- window width & height + -- '+ 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() + self:go_history(1) +end + +-- Stop browsing history and start editing a blank line (PgDown) +function em:handle_pgdown() + self:go_history(#self.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 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 }, + { 'pgup', function() self:handle_pgup() end }, + { 'pgdwn', function() self:handle_pgdown() 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/xfce-custom/dots/home/neko/.config/mpv/scripts/command_palette.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/command_palette.lua new file mode 100644 index 0000000..2080496 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/command_palette.lua @@ -0,0 +1,445 @@ + +-- https://github.com/stax76/mpv-scripts + +----- options + +local o = { + lines_to_show = 10, + pause_on_open = false, -- does not work on my system when enabled, menu won't show + resume_on_exit = "only-if-was-paused", + + -- styles + font_size = 50, + line_bottom_margin = 1, + menu_x_padding = 5, + menu_y_padding = 2, +} + +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 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 + +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 osc_visibility = 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 }, + } + + 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 and osc_visibility then + mp.command("script-message osc-visibility " .. osc_visibility .. " no_osd") + osc_visibility = nil + end +end + +menu.index_field = "index" + +function get_media_info() + local path = mp.get_property("path") + + 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 + + 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, "\\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 a = 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' + a:append(self:get_font_color('comment')) + + if comment ~= "" then + a:append(comment .. '\\h') + end + + a:append(key .. '\\h(' .. why_inactive .. ')' .. '\\h' .. cmd) + return a.text + end + + if comment ~= "" then + a:append(self:get_font_color('default')) + a:append(comment .. '\\h') + end + + a:append(self:get_font_color('accent')) + a:append(key) + a:append(self:get_font_color('comment')) + a:append(' ' .. cmd) + return a.text +end + +mp.register_script_message("show-command-palette", function (name) + menu_content.list = {} + menu_content.current_i = 1 + menu.search_heading = first_to_upper(name) + menu.filter_by_fields = { "content" } + em.get_line = original_get_line_func + + if 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 + 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(val) + mp.command(val.cmd) + end + + menu.filter_by_fields = {'cmd', 'key', 'comment'} + em.get_line = binding_get_line + elseif name == "chapters" then + local count = mp.get_property("chapter-list/count") + if count == 0 then return end + + for i = 0, count do + local title = mp.get_property("chapter-list/" .. i .. "/title") + + if title then + table.insert(menu_content.list, { index = i + 1, content = title }) + end + end + + menu_content.current_i = mp.get_property_number("chapter") + 1 + + function menu:submit(val) + mp.set_property_number("chapter", val.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(val) + mp.set_property_number("playlist-pos", val.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(val) + print(val.content) + local cmd = string.match(val.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(val) + mp.commandv('script-message-to', 'console', 'type', "print-text ${" .. val.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(val) + print(val.content) + local prop = string.match(val.content, '%S+') + mp.commandv("script-message-to", "console", "type", "set " .. prop .. " ") + end + elseif name == "audio" then + local tracks = split(get_media_info() .. "\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(val) + mp.command("set aid " .. ((val.index == id) and 'no' or val.index)) + end + elseif name == "subtitle" then + local tracks = split(get_media_info() .. "\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(val) + mp.command("set sid " .. ((val.index == id) and 'no' or val.index)) + end + elseif name == "video" then + local tracks = split(get_media_info() .. "\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(val) + mp.command("set vid " .. ((val.index == id) and 'no' or val.index)) + 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(val) + mp.command("show-text " .. val.content); + mp.command("apply-profile " .. val.content); + end + else + msg.error("Unknown mode " .. name) + return + end + + if is_empty(mp.get_property("path")) then + osc_visibility = utils.shared_script_property_get("osc-visibility") + + if osc_visibility then + mp.command("script-message osc-visibility never no_osd") + end + else + osc_visibility = nil + end + + menu:init(menu_content) +end) diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/mpv-cheatsheet-v0.30.0.2.js b/xfce-custom/dots/home/neko/.config/mpv/scripts/mpv-cheatsheet-v0.30.0.2.js new file mode 100644 index 0000000..8eec5af --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/mpv-cheatsheet-v0.30.0.2.js @@ -0,0 +1,553 @@ +(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i 0) { + this.text = this.text + "\n" + } +} + +Assdraw.prototype.override = function(callback) { + this.append('{') + callback.call(this); + this.append('}') +} + +Assdraw.prototype.primaryFillColor = function(hex) { + this.append('\\1c&H' + hex + '&') + return this; +} + +Assdraw.prototype.secondaryFillColor = function(hex) { + this.append('\\2c&H' + hex + '&') + return this; +} + +Assdraw.prototype.borderColor = function(hex) { + this.append('\\3c&H' + hex + '&') + return this; +} + +Assdraw.prototype.shadowColor = function(hex) { + this.append('\\4c&H' + hex + '&') + return this; +} + +Assdraw.prototype.primaryFillAlpha = function(hex) { + this.append('\\1a&H' + hex + '&') + return this; +} + +Assdraw.prototype.secondaryFillAlpha = function(hex) { + this.append('\\2a&H' + hex + '&') + return this; +} + +Assdraw.prototype.borderAlpha = function(hex) { + this.append('\\3a&H' + hex + '&') + return this; +} + +Assdraw.prototype.shadowAlpha = function(hex) { + this.append('\\4a&H' + hex + '&') + return this; +} + +Assdraw.prototype.fontName = function(s) { + this.append('\\fn' + s) + return this; +} + +Assdraw.prototype.fontSize = function(s) { + this.append('\\fs' + s) + return this; +} + +Assdraw.prototype.borderSize = function(n) { + this.append('\\bord' + n) + return this; +} + +Assdraw.prototype.xShadowDistance = function(n) { + this.append('\\xshad' + n) + return this; +} + +Assdraw.prototype.yShadowDistance = function(n) { + this.append('\\yshad' + n) + return this; +} + +Assdraw.prototype.letterSpacing = function(n) { + this.append('\\fsp' + n) + return this; +} + +Assdraw.prototype.wrapStyle = function(n) { + this.append('\\q' + n) + return this; +} + +Assdraw.prototype.drawStart = function() { + this.text = this.text + "{\\p"+ this.scale + "}" +} + +Assdraw.prototype.drawStop = function() { + this.text = this.text + "{\\p0}" +} + +Assdraw.prototype.coord = function(x, y) { + var scale = Math.pow(2, (this.scale - 1)) + var ix = Math.ceil(x * scale) + var iy = Math.ceil(y * scale) + this.text = this.text + " " + ix + " " + iy +} + +Assdraw.prototype.append = function(s) { + this.text = this.text + s +} + +Assdraw.prototype.appendLn = function(s) { + this.append(s + '\\n') +} + +Assdraw.prototype.appendLN = function(s) { + this.append(s + '\\N') +} + +Assdraw.prototype.merge = function(other) { + this.text = this.text + other.text +} + +Assdraw.prototype.pos = function(x, y) { + this.append("\\pos(" + x.toFixed(0) + "," + y.toFixed(0) + ")") +} + +Assdraw.prototype.lineAlignment = function(an) { + this.append("\\an" + an) +} + +Assdraw.prototype.moveTo = function(x, y) { + this.append(" m") + this.coord(x, y) +} + +Assdraw.prototype.lineTo = function(x, y) { + this.append(" l") + this.coord(x, y) +} + +Assdraw.prototype.bezierCurve = function(x1, y1, x2, y2, x3, y3) { + this.append(" b") + this.coord(x1, y1) + this.coord(x2, y2) + this.coord(x3, y3) +} + + +Assdraw.prototype.rectCcw = function(x0, y0, x1, y1) { + this.moveTo(x0, y0) + this.lineTo(x0, y1) + this.lineTo(x1, y1) + this.lineTo(x1, y0) +} + +Assdraw.prototype.rectCw = function(x0, y0, x1, y1) { + this.moveTo(x0, y0) + this.lineTo(x1, y0) + this.lineTo(x1, y1) + this.lineTo(x0, y1) +} + +Assdraw.prototype.hexagonCw = function(x0, y0, x1, y1, r1, r2) { + if (typeof r2 === 'undefined') { + r2 = r1 + } + this.moveTo(x0 + r1, y0) + if (x0 != x1) { + this.lineTo(x1 - r2, y0) + } + this.lineTo(x1, y0 + r2) + if (x0 != x1) { + this.lineTo(x1 - r2, y1) + } + this.lineTo(x0 + r1, y1) + this.lineTo(x0, y0 + r1) +} + +Assdraw.prototype.hexagonCcw = function(x0, y0, x1, y1, r1, r2) { + if (typeof r2 === 'undefined') { + r2 = r1 + } + this.moveTo(x0 + r1, y0) + this.lineTo(x0, y0 + r1) + this.lineTo(x0 + r1, y1) + if (x0 != x1) { + this.lineTo(x1 - r2, y1) + } + this.lineTo(x1, y0 + r2) + if (x0 != x1) { + this.lineTo(x1 - r2, y0) + } +} + +Assdraw.prototype.roundRectCw = function(ass, x0, y0, x1, y1, r1, r2) { + if (typeof r2 === 'undefined') { + r2 = r1 + } + var c1 = c * r1 // circle approximation + var c2 = c * r2 // circle approximation + this.moveTo(x0 + r1, y0) + this.lineTo(x1 - r2, y0) // top line + if (r2 > 0) { + this.bezierCurve(x1 - r2 + c2, y0, x1, y0 + r2 - c2, x1, y0 + r2) // top right corner + } + this.lineTo(x1, y1 - r2) // right line + if (r2 > 0) { + this.bezierCurve(x1, y1 - r2 + c2, x1 - r2 + c2, y1, x1 - r2, y1) // bottom right corner + } + this.lineTo(x0 + r1, y1) // bottom line + if (r1 > 0) { + this.bezierCurve(x0 + r1 - c1, y1, x0, y1 - r1 + c1, x0, y1 - r1) // bottom left corner + } + this.lineTo(x0, y0 + r1) // left line + if (r1 > 0) { + this.bezierCurve(x0, y0 + r1 - c1, x0 + r1 - c1, y0, x0 + r1, y0) // top left corner + } +} + +Assdraw.prototype.roundRectCcw = function(ass, x0, y0, x1, y1, r1, r2) { + if (typeof r2 === 'undefined') { + r2 = r1 + } + var c1 = c * r1 // circle approximation + var c2 = c * r2 // circle approximation + this.moveTo(x0 + r1, y0) + if (r1 > 0) { + this.bezierCurve(x0 + r1 - c1, y0, x0, y0 + r1 - c1, x0, y0 + r1) // top left corner + } + this.lineTo(x0, y1 - r1) // left line + if (r1 > 0) { + this.bezierCurve(x0, y1 - r1 + c1, x0 + r1 - c1, y1, x0 + r1, y1) // bottom left corner + } + this.lineTo(x1 - r2, y1) // bottom line + if (r2 > 0) { + this.bezierCurve(x1 - r2 + c2, y1, x1, y1 - r2 + c2, x1, y1 - r2) // bottom right corner + } + this.lineTo(x1, y0 + r2) // right line + if (r2 > 0) { + this.bezierCurve(x1, y0 + r2 - c2, x1 - r2 + c2, y0, x1 - r2, y0) // top right corner + } +} + +module.exports = Assdraw + +},{}],2:[function(require,module,exports){ +var assdraw = require('./assdraw.js') +var shortcuts = [ + { + category: 'Navigation', + shortcuts: [ + {keys: ', / .', effect: 'Seek by frame'}, + {keys: '← / →', effect: 'Seek by 5 seconds'}, + {keys: '↓ / ↑', effect: 'Seek by 1 minute'}, + {keys: '[Shift] PGDWN / PGUP', effect: 'Seek by 10 minutes'}, + {keys: '[Shift] ← / →', effect: 'Seek by 1 second (exact)'}, + {keys: '[Shift] ↓ / ↑', effect: 'Seek by 5 seconds (exact)'}, + {keys: '[Ctrl] ← / →', effect: 'Seek by subtitle'}, + {keys: '[Shift] BACKSPACE', effect: 'Undo last seek'}, + {keys: '[Ctrl+Shift] BACKSPACE', effect: 'Mark current position'}, + {keys: 'l', effect: 'Set/clear A-B loop points'}, + {keys: 'L', effect: 'Toggle infinite looping'}, + {keys: 'PGDWN / PGUP', effect: 'Previous/next chapter'}, + {keys: '< / >', effect: 'Go backward/forward in the playlist'}, + {keys: 'ENTER', effect: 'Go forward in the playlist'}, + {keys: 'F8', effect: 'Show playlist [UI]'}, + ] + }, + { + category: 'Playback', + shortcuts: [ + {keys: 'p / SPACE', effect: 'Pause/unpause'}, + {keys: '[ / ]', effect: 'Decrease/increase speed [10%]'}, + {keys: '{ / }', effect: 'Halve/double speed'}, + {keys: 'BACKSPACE', effect: 'Reset speed'}, + {keys: 'o / P', effect: 'Show progress'}, + {keys: 'O', effect: 'Toggle progress'}, + {keys: 'i / I', effect: 'Show/toggle stats'}, + ] + }, + { + category: 'Subtitle', + shortcuts: [ + {keys: '[Ctrl+Shift] ← / →', effect: 'Adjust subtitle delay [subtitle]'}, + {keys: 'z / Z', effect: 'Adjust subtitle delay [0.1sec]'}, + {keys: 'v', effect: 'Toggle subtitle visibility'}, + {keys: 'u', effect: 'Toggle subtitle style overrides'}, + {keys: 'V', effect: 'Toggle subtitle VSFilter aspect compatibility mode'}, + {keys: 'r / R', effect: 'Move subtitles up/down'}, + {keys: 'j / J', effect: 'Cycle subtitle'}, + {keys: 'F9', effect: 'Show audio/subtitle list [UI]'}, + ] + }, + { + category: 'Audio', + shortcuts: [ + {keys: 'm', effect: 'Mute sound'}, + {keys: '#', effect: 'Cycle audio track'}, + {keys: '/ / *', effect: 'Decrease/increase volume'}, + {keys: '9 / 0', effect: 'Decrease/increase volume'}, + {keys: '[Ctrl] - / +', effect: 'Decrease/increase audio delay [0.1sec]'}, + {keys: 'F9', effect: 'Show audio/subtitle list [UI]'}, + ] + }, + { + category: 'Video', + shortcuts: [ + {keys: '_', effect: 'Cycle video track'}, + {keys: 'A', effect: 'Cycle aspect ratio'}, + {keys: 'd', effect: 'Toggle deinterlacer'}, + {keys: '[Ctrl] h', effect: 'Toggle hardware video decoding'}, + {keys: 'w / W', effect: 'Decrease/increase pan-and-scan range'}, + {keys: '[Alt] - / +', effect: 'Zoom out/in'}, + {keys: '[Alt] ARROWS', effect: 'Move the video rectangle'}, + {keys: '[Alt] BACKSPACE', effect: 'Reset pan/zoom'}, + {keys: '1 / 2', effect: 'Decrease/increase contrast'}, + {keys: '3 / 4', effect: 'Decrease/increase brightness'}, + {keys: '5 / 6', effect: 'Decrease/increase gamma'}, + {keys: '7 / 8', effect: 'Decrease/increase saturation'}, + ] + }, + { + category: 'Application', + shortcuts: [ + {keys: 'q', effect: 'Quit'}, + {keys: 'Q', effect: 'Save position and quit'}, + {keys: 's', effect: 'Take a screenshot'}, + {keys: 'S', effect: 'Take a screenshot without subtitles'}, + {keys: '[Ctrl] s', effect: 'Take a screenshot as rendered'}, + ] + }, + { + category: 'Window', + shortcuts: [ + {keys: 'f', effect: 'Toggle fullscreen'}, + {keys: '[Command] f', effect: 'Toggle fullscreen [macOS]'}, + {keys: 'ESC', effect: 'Exit fullscreen'}, + {keys: 'T', effect: 'Toggle stay-on-top'}, + {keys: '[Alt] 0', effect: 'Resize window to 0.5x [macOS]'}, + {keys: '[Alt] 1', effect: 'Reset window size [macOS]'}, + {keys: '[Alt] 2', effect: 'Resize window to 2x [macOS]'}, + ] + }, + { + category: 'Multimedia keys', + shortcuts: [ + {keys: 'PAUSE', effect: 'Pause'}, // keyboard with multimedia keys + {keys: 'STOP', effect: 'Quit'}, // keyboard with multimedia keys + {keys: 'PREVIOUS / NEXT', effect: 'Seek 1 minute'}, // keyboard with multimedia keys + ] + }, +] + +var State = { + active: false, + startLine: 0, + startCategory: 0 +} + +var opts = { + font: 'monospace', + 'font-size': 8, + 'usage-font-size': 6, +} + +function repeat(s, num) { + var ret = ''; + for (var i = 0; i < num; i++) { + ret = ret + s; + } + return ret; +} + +function renderCategory(category) { + var lines = [] + lines.push(assdraw.bolden(category.category)) + var maxKeysLength = 0; + category.shortcuts.forEach(function(shortcut) { + if (shortcut.keys.length > maxKeysLength) maxKeysLength = shortcut.keys.length + }) + category.shortcuts.forEach(function(shortcut) { + var padding = repeat(" ", maxKeysLength - shortcut.keys.length) + lines.push(assdraw.escape(shortcut.keys + padding + " " + shortcut.effect)) + }) + return lines +} + +function render() { + var screen = mp.get_osd_size() + if (!State.active) { + mp.set_osd_ass(0, 0, '{}') + return + } + var ass = new assdraw() + ass.newEvent() + ass.override(function() { + this.lineAlignment(assdraw.TOP_LEFT) + this.primaryFillAlpha('00') + this.borderAlpha('00') + this.shadowAlpha('99') + this.primaryFillColor('eeeeee') + this.borderColor('111111') + this.shadowColor('000000') + this.fontName(opts.font) + this.fontSize(opts['font-size']) + this.borderSize(1) + this.xShadowDistance(0) + this.yShadowDistance(1) + this.letterSpacing(0) + this.wrapStyle(assdraw.EOL_WRAPPING) + }) + var mainLines = []; + var pushedCategory = false + shortcuts.forEach(function(category, i) { + if (i < State.startCategory) { + return; + } + pushedCategory = true; + if (pushedCategory) { + mainLines.push("") + } + mainLines.push.apply(mainLines, renderCategory(category)) + }) + mainLines.slice(State.startLine).forEach(function(line) { + ass.appendLN(line); + }) + + ass.newEvent() + var sideLines = renderCategory({ + category: 'usage', + shortcuts: Keybindings + }) + ass.override(function() { + this.lineAlignment(assdraw.TOP_RIGHT) + this.fontSize(opts['usage-font-size']) + }) + sideLines.forEach(function(line) { + ass.appendLN(line); + }) + + mp.set_osd_ass(0, 0, ass.text) +} + +function setActive(active) { + if (active == State.active) return + if (active) { + State.active = true + updateBindings(Keybindings, true) + } else { + State.active = false + updateBindings(Keybindings, false) + } + render() +} + +function updateBindings(bindings, enable) { + bindings.forEach(function(binding, i) { + var name = '__cheatsheet_binding_' + i + if (enable) { + mp.add_forced_key_binding(binding.keys, name, binding.callback, binding.options) + } else { + mp.remove_key_binding(name) + } + }) +} + +var Keybindings = [ + { + keys: 'esc', + effect: 'close', + callback: function() { setActive(false) } + }, + { + keys: '?', + effect: 'close', + callback: function() { setActive(false) } + }, + { + keys: 'j', + effect: 'next line', + callback: function() { + State.startLine += 1 + render() + }, + options: 'repeatable' + }, + { + keys: 'k', + effect: 'prev line', + callback: function() { + State.startLine = Math.max(0, State.startine - 1) + render() + }, + options: 'repeatable' + }, + { + keys: 'n', + effect: 'next category', + callback: function() { + State.startCategory += 1 + State.startLine = 0 + render() + }, + options: 'repeatable' + }, + { + keys: 'p', + effect: 'prev category', + callback: function() { + State.startCategory = Math.max(0, State.startCategory - 1) + State.startLine = 0 + render() + }, + options: 'repeatable' + }, +] + +mp.add_key_binding('?', 'cheatsheet-enable', function() { setActive(true) }) + +mp.observe_property('osd-width', 'native', render) +mp.observe_property('osd-height', 'native', render) + +},{"./assdraw.js":1}]},{},[2]); diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/reload.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/reload.lua new file mode 100644 index 0000000..73a7ec4 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/reload.lua @@ -0,0 +1,422 @@ +-- reload.lua +-- +-- When an online video is stuck buffering or got very slow CDN +-- source, restarting often helps. This script provides automatic +-- reloading of videos that doesn't have buffering progress for some +-- time while keeping the current time position. It also adds `Ctrl+r` +-- keybinding to reload video manually. +-- +-- SETTINGS +-- +-- To override default setting put the `lua-settings/reload.conf` file in +-- mpv user folder, on linux it is `~/.config/mpv`. NOTE: config file +-- name should match the name of the script. +-- +-- Default `reload.conf` settings: +-- +-- ``` +-- # enable automatic reload on timeout +-- # when paused-for-cache event fired, we will wait +-- # paused_for_cache_timer_timeout sedonds and then reload the video +-- paused_for_cache_timer_enabled=yes +-- +-- # checking paused_for_cache property interval in seconds, +-- # can not be less than 0.05 (50 ms) +-- paused_for_cache_timer_interval=1 +-- +-- # time in seconds to wait until reload +-- paused_for_cache_timer_timeout=10 +-- +-- # enable automatic reload based on demuxer cache +-- # if demuxer-cache-time property didn't change in demuxer_cache_timer_timeout +-- # time interval, the video will be reloaded as soon as demuxer cache depleated +-- demuxer_cache_timer_enabled=yes +-- +-- # checking demuxer-cache-time property interval in seconds, +-- # can not be less than 0.05 (50 ms) +-- demuxer_cache_timer_interval=2 +-- +-- # if demuxer cache didn't receive any data during demuxer_cache_timer_timeout +-- # we decide that it has no progress and will reload the stream when +-- # paused_for_cache event happens +-- demuxer_cache_timer_timeout=20 +-- +-- # when the end-of-file is reached, reload the stream to check +-- # if there is more content available. +-- reload_eof_enabled=no +-- +-- # keybinding to reload stream from current time position +-- # you can disable keybinding by setting it to empty value +-- # reload_key_binding= +-- reload_key_binding=Ctrl+r +-- ``` +-- +-- DEBUGGING +-- +-- Debug messages will be printed to stdout with mpv command line option +-- `--msg-level='reload=debug'`. You may also need to add the `--no-msg-color` +-- option to make the debug logs visible if you are using a dark colorscheme +-- in terminal. + +local msg = require 'mp.msg' +local options = require 'mp.options' +local utils = require 'mp.utils' + + +local settings = { + paused_for_cache_timer_enabled = true, + paused_for_cache_timer_interval = 1, + paused_for_cache_timer_timeout = 10, + demuxer_cache_timer_enabled = true, + demuxer_cache_timer_interval = 2, + demuxer_cache_timer_timeout = 20, + reload_eof_enabled = false, + reload_key_binding = "Ctrl+r", +} + +-- global state stores properties between reloads +local property_path = nil +local property_time_pos = 0 +local property_keep_open = nil + +-- FSM managing the demuxer cache. +-- +-- States: +-- +-- * fetch - fetching new data +-- * stale - unable to fetch new data for time < 'demuxer_cache_timer_timeout' +-- * stuck - unable to fetch new data for time >= 'demuxer_cache_timer_timeout' +-- +-- State transitions: +-- +-- +---------------------------+ +-- v | +-- +-------+ +-------+ +-------+ +-- + fetch +<--->+ stale +---->+ stuck | +-- +-------+ +-------+ +-------+ +-- | ^ | ^ | ^ +-- +---+ +---+ +---+ +local demuxer_cache = { + timer = nil, + + state = { + name = 'uninitialized', + demuxer_cache_time = 0, + in_state_time = 0, + }, + + events = { + continue_fetch = { name = 'continue_fetch', from = 'fetch', to = 'fetch' }, + continue_stale = { name = 'continue_stale', from = 'stale', to = 'stale' }, + continue_stuck = { name = 'continue_stuck', from = 'stuck', to = 'stuck' }, + fetch_to_stale = { name = 'fetch_to_stale', from = 'fetch', to = 'stale' }, + stale_to_fetch = { name = 'stale_to_fetch', from = 'stale', to = 'fetch' }, + stale_to_stuck = { name = 'stale_to_stuck', from = 'stale', to = 'stuck' }, + stuck_to_fetch = { name = 'stuck_to_fetch', from = 'stuck', to = 'fetch' }, + }, + +} + +-- Always start with 'fetch' state +function demuxer_cache.reset_state() + demuxer_cache.state = { + name = demuxer_cache.events.continue_fetch.to, + demuxer_cache_time = 0, + in_state_time = 0, + } +end + +-- Has 'demuxer_cache_time' changed +function demuxer_cache.has_progress_since(t) + return demuxer_cache.state.demuxer_cache_time ~= t +end + +function demuxer_cache.is_state_fetch() + return demuxer_cache.state.name == demuxer_cache.events.continue_fetch.to +end + +function demuxer_cache.is_state_stale() + return demuxer_cache.state.name == demuxer_cache.events.continue_stale.to +end + +function demuxer_cache.is_state_stuck() + return demuxer_cache.state.name == demuxer_cache.events.continue_stuck.to +end + +function demuxer_cache.transition(event) + if demuxer_cache.state.name == event.from then + + -- state setup + demuxer_cache.state.demuxer_cache_time = event.demuxer_cache_time + + if event.name == 'continue_fetch' then + demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval + elseif event.name == 'continue_stale' then + demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval + elseif event.name == 'continue_stuck' then + demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval + elseif event.name == 'fetch_to_stale' then + demuxer_cache.state.in_state_time = 0 + elseif event.name == 'stale_to_fetch' then + demuxer_cache.state.in_state_time = 0 + elseif event.name == 'stale_to_stuck' then + demuxer_cache.state.in_state_time = 0 + elseif event.name == 'stuck_to_fetch' then + demuxer_cache.state.in_state_time = 0 + end + + -- state transition + demuxer_cache.state.name = event.to + + msg.debug('demuxer_cache.transition', event.name, utils.to_string(demuxer_cache.state)) + else + msg.error( + 'demuxer_cache.transition', + 'illegal transition', event.name, + 'from state', demuxer_cache.state.name) + end +end + +function demuxer_cache.initialize(demuxer_cache_timer_interval) + demuxer_cache.reset_state() + demuxer_cache.timer = mp.add_periodic_timer( + demuxer_cache_timer_interval, + function() + demuxer_cache.demuxer_cache_timer_tick( + mp.get_property_native('demuxer-cache-time'), + demuxer_cache_timer_interval) + end + ) +end + +-- If there is no progress of demuxer_cache_time in +-- settings.demuxer_cache_timer_timeout time interval switch state to +-- 'stuck' and switch back to 'fetch' as soon as any progress is made +function demuxer_cache.demuxer_cache_timer_tick(demuxer_cache_time, demuxer_cache_timer_interval) + local event = nil + local cache_has_progress = demuxer_cache.has_progress_since(demuxer_cache_time) + + -- I miss pattern matching so much + if demuxer_cache.is_state_fetch() then + if cache_has_progress then + event = demuxer_cache.events.continue_fetch + else + event = demuxer_cache.events.fetch_to_stale + end + elseif demuxer_cache.is_state_stale() then + if cache_has_progress then + event = demuxer_cache.events.stale_to_fetch + elseif demuxer_cache.state.in_state_time < settings.demuxer_cache_timer_timeout then + event = demuxer_cache.events.continue_stale + else + event = demuxer_cache.events.stale_to_stuck + end + elseif demuxer_cache.is_state_stuck() then + if cache_has_progress then + event = demuxer_cache.events.stuck_to_fetch + else + event = demuxer_cache.events.continue_stuck + end + end + + event.demuxer_cache_time = demuxer_cache_time + event.interval = demuxer_cache_timer_interval + demuxer_cache.transition(event) +end + + +local paused_for_cache = { + timer = nil, + time = 0, +} + +function paused_for_cache.reset_timer() + msg.debug('paused_for_cache.reset_timer', paused_for_cache.time) + if paused_for_cache.timer then + paused_for_cache.timer:kill() + paused_for_cache.timer = nil + paused_for_cache.time = 0 + end +end + +function paused_for_cache.start_timer(interval_seconds, timeout_seconds) + msg.debug('paused_for_cache.start_timer', paused_for_cache.time) + if not paused_for_cache.timer then + paused_for_cache.timer = mp.add_periodic_timer( + interval_seconds, + function() + paused_for_cache.time = paused_for_cache.time + interval_seconds + if paused_for_cache.time >= timeout_seconds then + paused_for_cache.reset_timer() + reload_resume() + end + msg.debug('paused_for_cache', 'tick', paused_for_cache.time) + end + ) + end +end + +function paused_for_cache.handler(property, is_paused) + if is_paused then + + if demuxer_cache.is_state_stuck() then + msg.info("demuxer cache has no progress") + -- reset demuxer state to avoid immediate reload if + -- paused_for_cache event triggered right after reload + demuxer_cache.reset_state() + reload_resume() + end + + paused_for_cache.start_timer( + settings.paused_for_cache_timer_interval, + settings.paused_for_cache_timer_timeout) + else + paused_for_cache.reset_timer() + end +end + +function read_settings() + options.read_options(settings, mp.get_script_name()) + msg.debug(utils.to_string(settings)) +end + +function reload(path, time_pos) + msg.debug("reload", path, time_pos) + if time_pos == nil then + mp.commandv("loadfile", path, "replace") + else + mp.commandv("loadfile", path, "replace", "start=+" .. time_pos) + end +end + +function reload_resume() + local path = mp.get_property("path", property_path) + local time_pos = mp.get_property("time-pos") + local reload_duration = mp.get_property_native("duration") + + local playlist_count = mp.get_property_number("playlist/count") + local playlist_pos = mp.get_property_number("playlist-pos") + local playlist = {} + for i = 0, playlist_count-1 do + playlist[i] = mp.get_property("playlist/" .. i .. "/filename") + end + -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero + -- duration property. When reloading VOD, to keep the current time position + -- we should provide offset from the start. Stream doesn't have fixed start. + -- Decent choice would be to reload stream from it's current 'live' positon. + -- That's the reason we don't pass the offset when reloading streams. + if reload_duration and reload_duration > 0 then + msg.info("reloading video from", time_pos, "second") + reload(path, time_pos) + -- VODs get stuck when reload is called without a time_pos + -- this is most noticeable in youtube videos whenever download gets stuck in the first frames + -- video would stay paused without being actually paused + -- issue surfaced in mpv 0.33, afaik + elseif reload_duration and reload_duration == 0 then + msg.info("reloading video from", time_pos, "second") + reload(path, time_pos) + else + msg.info("reloading stream") + reload(path, nil) + end + msg.info("file", playlist_pos+1, "of", playlist_count, "in playlist") + for i = 0, playlist_pos-1 do + mp.commandv("loadfile", playlist[i], "append") + end + mp.commandv("playlist-move", 0, playlist_pos+1) + for i = playlist_pos+1, playlist_count-1 do + mp.commandv("loadfile", playlist[i], "append") + end +end + +function reload_eof(property, eof_reached) + msg.debug("reload_eof", property, eof_reached) + local time_pos = mp.get_property_number("time-pos") + local duration = mp.get_property_number("duration") + + if eof_reached and round(time_pos) == round(duration) then + msg.debug("property_time_pos", property_time_pos, "time_pos", time_pos) + + -- Check that playback time_pos made progress after the last reload. When + -- eof is reached we try to reload the video, in case there is more content + -- available. If time_pos stayed the same after reload, it means that the + -- video length stayed the same, and we can end the playback. + if round(property_time_pos) == round(time_pos) then + msg.info("eof reached, playback ended") + mp.set_property("keep-open", property_keep_open) + else + msg.info("eof reached, checking if more content available") + reload_resume() + mp.set_property_bool("pause", false) + property_time_pos = time_pos + end + end +end + +function on_file_loaded(event) + local debug_info = { + event = event, + time_pos = mp.get_property("time-pos"), + stream_pos = mp.get_property("stream-pos"), + stream_end = mp.get_property("stream-end"), + duration = mp.get_property("duration"), + seekable = mp.get_property("seekable"), + pause = mp.get_property("pause"), + paused_for_cache = mp.get_property("paused-for-cache"), + cache_buffering_state = mp.get_property("cache-buffering-state"), + } + msg.debug("debug_info", utils.to_string(debug_info)) + + -- When the video is reloaded after being paused for cache, it won't start + -- playing again while all properties looks fine: + -- `pause=no`, `paused-for-cache=no` and `cache-buffering-state=100`. + -- As a workaround, we cycle through the paused state by sending two SPACE + -- keypresses. + -- What didn't work: + -- - Cycling through the `pause` property. + -- - Run the `playlist-play-index current` command. + mp.commandv("keypress", 'SPACE') + mp.commandv("keypress", 'SPACE') +end + +-- Round positive numbers. +function round(num) + return math.floor(num + 0.5) +end + +-- main + +read_settings() + +if settings.reload_key_binding ~= "" then + mp.add_key_binding(settings.reload_key_binding, "reload_resume", reload_resume) +end + +if settings.paused_for_cache_timer_enabled then + mp.observe_property("paused-for-cache", "bool", paused_for_cache.handler) +end + +if settings.demuxer_cache_timer_enabled then + demuxer_cache.initialize(settings.demuxer_cache_timer_interval) +end + +if settings.reload_eof_enabled then + -- vo-configured == video output created && its configuration went ok + mp.observe_property( + "vo-configured", + "bool", + function(name, vo_configured) + msg.debug(name, vo_configured) + if vo_configured then + property_path = mp.get_property("path") + property_keep_open = mp.get_property("keep-open") + mp.set_property("keep-open", "yes") + mp.set_property("keep-open-pause", "no") + end + end + ) + + mp.observe_property("eof-reached", "bool", reload_eof) +end + +mp.register_event("file-loaded", on_file_loaded) diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock.lua new file mode 100644 index 0000000..96bfb24 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock.lua @@ -0,0 +1,569 @@ +-- sponsorblock.lua +-- +-- This script skips sponsored segments of YouTube videos +-- using data from https://github.com/ajayyy/SponsorBlock + +local ON_WINDOWS = package.config:sub(1,1) ~= "/" + +local options = { + server_address = "https://sponsor.ajay.app", + + python_path = ON_WINDOWS and "python" or "python3", + + -- Categories to fetch + categories = "sponsor,intro,outro,interaction,selfpromo,filler", + + -- Categories to skip automatically + skip_categories = "sponsor", + + -- If true, sponsored segments will only be skipped once + skip_once = true, + + -- Note that sponsored segments may ocasionally be inaccurate if this is turned off + -- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker + local_database = false, + + -- Update database on first run, does nothing if local_database is false + auto_update = true, + + -- How long to wait between local database updates + -- Format: "X[d,h,m]", leave blank to update on every mpv run + auto_update_interval = "6h", + + -- User ID used to submit sponsored segments, leave blank for random + user_id = "", + + -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name + display_name = "", + + -- Tell the server when a skip happens + report_views = true, + + -- Auto upvote skipped sponsors + auto_upvote = false, + + -- Use sponsor times from server if they're more up to date than our local database + server_fallback = true, + + -- Create chapters at sponsor boundaries for OSC display and manual skipping + make_chapters = true, + + -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored + min_duration = 1, + + -- Fade audio for smoother transitions + audio_fade = false, + + -- Audio fade step, applied once every 100ms until cap is reached + audio_fade_step = 10, + + -- Audio fade cap + audio_fade_cap = 0, + + -- Fast forward through sponsors instead of skipping + fast_forward = false, + + -- Playback speed modifier when fast forwarding, applied once every second until cap is reached + fast_forward_increase = .2, + + -- Playback speed cap + fast_forward_cap = 2, + + -- Length of the sha256 prefix (3-32) when querying server, 0 to disable + sha256_length = 4, + + -- Pattern for video id in local files, ignored if blank + -- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$" + local_pattern = "", + + -- Legacy option, use skip_categories instead + skip = true +} + +mp.options = require "mp.options" +mp.options.read_options(options, "sponsorblock") + +local legacy = mp.command_native_async == nil +--[[ +if legacy then + options.local_database = false +end +--]] +options.local_database = false + +local utils = require "mp.utils" +scripts_dir = mp.find_config_file("scripts") + +local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py") +local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt") +local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or "" +local youtube_id = nil +local ranges = {} +local init = false +local segment = {a = 0, b = 0, progress = 0, first = true} +local retrying = false +local last_skip = {uuid = "", dir = nil} +local speed_timer = nil +local fade_timer = nil +local fade_dir = nil +local volume_before = mp.get_property_number("volume") +local categories = {} +local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "filler"} +local chapter_cache = {} + +for category in string.gmatch(options.skip_categories, "([^,]+)") do + categories[category] = true +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 t_count(t) + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + +function time_sort(a, b) + if a.time == b.time then + return string.match(a.title, "segment end") + end + return a.time < b.time +end + +function parse_update_interval() + local s = options.auto_update_interval + if s == "" then return 0 end -- Interval Disabled + + local num, mod = s:match "^(%d+)([hdm])$" + + if num == nil or mod == nil then + mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5) + return nil + end + + local time_table = { + m = 60, + h = 60 * 60, + d = 60 * 60 * 24, + } + + return num * time_table[mod] +end + +function clean_chapters() + local chapters = mp.get_property_native("chapter-list") + local new_chapters = {} + for _, chapter in pairs(chapters) do + if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then + table.insert(new_chapters, chapter) + end + end + mp.set_property_native("chapter-list", new_chapters) +end + +function create_chapter(chapter_title, chapter_time) + local chapters = mp.get_property_native("chapter-list") + local duration = mp.get_property_native("duration") + table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001}) + table.sort(chapters, time_sort) + mp.set_property_native("chapter-list", chapters) +end + +function process(uuid, t, new_ranges) + start_time = tonumber(string.match(t, "[^,]+")) + end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2)) + for o_uuid, o_t in pairs(ranges) do + if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then + new_ranges[o_uuid] = o_t + return + end + end + category = string.match(t, "[^,]+$") + if categories[category] and end_time - start_time >= options.min_duration then + new_ranges[uuid] = { + start_time = start_time, + end_time = end_time, + category = category, + skipped = false + } + end + if options.make_chapters and not chapter_cache[uuid] then + chapter_cache[uuid] = true + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time) + create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time) + end +end + +function getranges(_, exists, db, more) + if type(exists) == "table" and exists["status"] == "1" then + if options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + else + return mp.osd_message("[sponsorblock] database update failed, gave up") + end + end + if db ~= "" and db ~= database_file then db = database_file end + if exists ~= true and not file_exists(db) then + if not retrying then + mp.osd_message("[sponsorblock] database update failed, retrying...") + retrying = true + end + return update() + end + if retrying then + mp.osd_message("[sponsorblock] database update succeeded") + retrying = false + end + local sponsors + local args = { + options.python_path, + sponsorblock, + "ranges", + db, + options.server_address, + youtube_id, + options.categories, + tostring(options.sha256_length) + } + if not legacy then + sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + sponsors = utils.subprocess({args = args}) + end + mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", "")) + if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end + if string.match(sponsors.stdout, "error") then return getranges(true, true) end + local new_ranges = {} + local r_count = 0 + if more then r_count = -1 end + for t in string.gmatch(sponsors.stdout, "[^:%s]+") do + uuid = string.match(t, "([^,]+),[^,]+$") + if ranges[uuid] then + new_ranges[uuid] = ranges[uuid] + else + process(uuid, t, new_ranges) + end + r_count = r_count + 1 + end + local c_count = t_count(ranges) + if c_count == 0 or r_count >= c_count then + ranges = new_ranges + end +end + +function fast_forward() + if options.fast_forward and options.fast_forward == true then + speed_timer = nil + mp.set_property("speed", 1) + end + local last_speed = mp.get_property_number("speed") + local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) + if new_speed <= last_speed then return end + mp.set_property("speed", new_speed) +end + +function fade_audio(step) + local last_volume = mp.get_property_number("volume") + local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) + if new_volume == last_volume then + if step >= 0 then fade_dir = nil end + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = nil + return + end + mp.set_property("volume", new_volume) +end + +function skip_ads(name, pos) + if pos == nil then return end + local sponsor_ahead = false + for uuid, t in pairs(ranges) do + if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then + if options.fast_forward == uuid then return end + if options.fast_forward == false then + mp.osd_message("[sponsorblock] " .. t.category .. " skipped") + mp.set_property("time-pos", t.end_time) + else + mp.osd_message("[sponsorblock] skipping " .. t.category) + end + t.skipped = true + last_skip = {uuid = uuid, dir = nil} + if options.report_views or options.auto_upvote then + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + uuid, + options.report_views and "1" or "", + uid_path, + options.user_id, + options.auto_upvote and "1" or "" + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if options.fast_forward ~= false then + options.fast_forward = uuid + if speed_timer ~= nil then speed_timer:kill() end + speed_timer = mp.add_periodic_timer(1, fast_forward) + end + return + elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then + sponsor_ahead = true + end + end + if options.audio_fade then + if sponsor_ahead then + if fade_dir ~= false then + if fade_dir == nil then volume_before = mp.get_property_number("volume") end + if fade_timer ~= nil then fade_timer:kill() end + fade_dir = false + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) + end + elseif fade_dir == false then + fade_dir = true + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) + end + end + if options.fast_forward and options.fast_forward ~= true then + options.fast_forward = true + speed_timer:kill() + speed_timer = nil + mp.set_property("speed", 1) + end +end + +function vote(dir) + if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end + local updown = dir == "1" and "up" or "down" + if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end + last_skip.dir = dir + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + last_skip.uuid, + "", + uid_path, + options.user_id, + dir + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess({args = args}) + end + mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") +end + +function update() + mp.command_native_async({name = "subprocess", playback_only = false, args = { + options.python_path, + sponsorblock, + "update", + database_file, + options.server_address + }}, getranges) +end + +function file_loaded() + local initialized = init + ranges = {} + segment = {a = 0, b = 0, progress = 0, first = true} + last_skip = {uuid = "", dir = nil} + chapter_cache = {} + local video_path = mp.get_property("path", "") + mp.msg.debug("Path: " .. video_path) + local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" + mp.msg.debug("Referer: " .. video_referer) + + local urls = { + "ytdl://([%w-_]+).*", + "https?://youtu%.be/([%w-_]+).*", + "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "/watch.*[?&]v=([%w-_]+).*", + "/embed/([%w-_]+).*" + } + youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) + if youtube_id then break end + end + youtube_id = youtube_id or string.match(video_path, options.local_pattern) + + if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end + youtube_id = string.sub(youtube_id, 1, 11) + mp.msg.debug("Found YouTube ID: " .. youtube_id) + init = true + if not options.local_database then + getranges(true, true) + else + local exists = file_exists(database_file) + if exists and options.server_fallback then + getranges(true, true) + mp.add_timeout(0, function() getranges(true, true, "", true) end) + elseif exists then + getranges(true, true) + elseif options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + end + end + if initialized then return end + if options.skip then + mp.observe_property("time-pos", "native", skip_ads) + end + if options.display_name ~= "" then + local args = { + options.python_path, + sponsorblock, + "username", + database_file, + options.server_address, + youtube_id, + "", + "", + uid_path, + options.user_id, + options.display_name + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end + + if file_exists(database_file) then + local db_info = utils.file_info(database_file) + local cur_time = os.time(os.date("*t")) + local upd_interval = parse_update_interval() + if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end + end + + update() +end + +function set_segment() + if not youtube_id then return end + local pos = mp.get_property_number("time-pos") + if pos == nil then return end + if segment.progress > 1 then + segment.progress = segment.progress - 2 + end + if segment.progress == 1 then + segment.progress = 0 + segment.b = pos + mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) + else + segment.progress = 1 + segment.a = pos + mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) + end + if options.make_chapters and not segment.first then + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time ~= 0 and end_time ~= 0 then + clean_chapters() + create_chapter("Preview segment start", start_time) + create_chapter("Preview segment end", end_time) + end + end + segment.first = false +end + +function select_category(selected) + for category in string.gmatch(options.categories, "([^,]+)") do + mp.remove_key_binding("select_category_"..category) + mp.remove_key_binding("kp_select_category_"..category) + end + submit_segment(selected) +end + +function submit_segment(category) + if not youtube_id then return end + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time == 0 or end_time == 0 then + mp.osd_message("[sponsorblock] empty segment, not submitting") + elseif segment.progress <= 1 then + segment.progress = segment.progress + 2 + local category_list = "" + for category_id, category in pairs(all_categories) do + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + category_list = category_list .. category_id .. ": " .. category_title .. "\n" + mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end) + mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end) + end + mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30) + else + mp.osd_message("[sponsorblock] submitting segment...", 30) + local submit + local args = { + options.python_path, + sponsorblock, + "submit", + database_file, + options.server_address, + youtube_id, + tostring(start_time), + tostring(end_time), + uid_path, + options.user_id, + category or "sponsor" + } + if not legacy then + submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + submit = utils.subprocess({args = args}) + end + if string.match(submit.stdout, "success") then + segment = {a = 0, b = 0, progress = 0, first = true} + mp.osd_message("[sponsorblock] segment submitted") + if options.make_chapters then + clean_chapters() + create_chapter("Submitted segment start", start_time) + create_chapter("Submitted segment end", end_time) + end + elseif string.match(submit.stdout, "error") then + mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) + elseif string.match(submit.stdout, "502") then + mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) + elseif string.match(submit.stdout, "400") then + mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) + segment = {a = 0, b = 0, progress = 0, first = true} + elseif string.match(submit.stdout, "429") then + mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) + elseif string.match(submit.stdout, "409") then + mp.osd_message("[sponsorblock] segment already submitted", 3) + segment = {a = 0, b = 0, progress = 0, first = true} + else + mp.osd_message("[sponsorblock] segment submission failed", 5) + end + end +end + +mp.register_event("file-loaded", file_loaded) +mp.add_key_binding("g", "set_segment", set_segment) +mp.add_key_binding("G", "submit_segment", submit_segment) +mp.add_key_binding("h", "upvote_segment", function() return vote("1") end) +mp.add_key_binding("H", "downvote_segment", function() return vote("0") end) +-- Bindings below are for backwards compatibility and could be removed at any time +mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment) +mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment) +mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end) +mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end) diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/main.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/main.lua new file mode 100644 index 0000000..2bbe7a2 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/main.lua @@ -0,0 +1,3 @@ +-- This is a dummy main.lua +-- required for mpv 0.33 +-- do not delete \ No newline at end of file diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py new file mode 100644 index 0000000..8370a6a --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py @@ -0,0 +1,122 @@ +import urllib.request +import urllib.parse +import hashlib +import sqlite3 +import random +import string +import json +import sys +import os + +if sys.argv[1] in ["submit", "stats", "username"]: + if not sys.argv[8]: + if os.path.isfile(sys.argv[7]): + with open(sys.argv[7]) as f: + uid = f.read() + else: + uid = "".join(random.choices(string.ascii_letters + string.digits, k=36)) + with open(sys.argv[7], "w") as f: + f.write(uid) + else: + uid = sys.argv[8] + +opener = urllib.request.build_opener() +opener.addheaders = [("User-Agent", "mpv_sponsorblock/1.0 (https://github.com/po5/mpv_sponsorblock)")] +urllib.request.install_opener(opener) + +if sys.argv[1] == "ranges" and (not sys.argv[2] or not os.path.isfile(sys.argv[2])): + sha = None + if 3 <= int(sys.argv[6]) <= 32: + sha = hashlib.sha256(sys.argv[4].encode()).hexdigest()[:int(sys.argv[6])] + times = [] + try: + response = urllib.request.urlopen(sys.argv[3] + "/api/skipSegments" + ("/" + sha + "?" if sha else "?videoID=" + sys.argv[4] + "&") + urllib.parse.urlencode([("categories", json.dumps(sys.argv[5].split(",")))])) + segments = json.load(response) + for segment in segments: + if sha and sys.argv[4] != segment["videoID"]: + continue + if sha: + for s in segment["segments"]: + times.append(str(s["segment"][0]) + "," + str(s["segment"][1]) + "," + s["UUID"] + "," + s["category"]) + else: + times.append(str(segment["segment"][0]) + "," + str(segment["segment"][1]) + "," + segment["UUID"] + "," + segment["category"]) + print(":".join(times)) + except (TimeoutError, urllib.error.URLError) as e: + print("error") + except urllib.error.HTTPError as e: + if e.code == 404: + print("") + else: + print("error") +elif sys.argv[1] == "ranges": + conn = sqlite3.connect(sys.argv[2]) + conn.row_factory = sqlite3.Row + c = conn.cursor() + times = [] + for category in sys.argv[5].split(","): + c.execute("SELECT startTime, endTime, votes, UUID, category FROM sponsorTimes WHERE videoID = ? AND shadowHidden = 0 AND votes > -1 AND category = ?", (sys.argv[4], category)) + sponsors = c.fetchall() + best = list(sponsors) + dealtwith = [] + similar = [] + for sponsor_a in sponsors: + for sponsor_b in sponsors: + if sponsor_a is not sponsor_b and sponsor_a["startTime"] >= sponsor_b["startTime"] and sponsor_a["startTime"] <= sponsor_b["endTime"]: + similar.append([sponsor_a, sponsor_b]) + if sponsor_a in best: + best.remove(sponsor_a) + if sponsor_b in best: + best.remove(sponsor_b) + for sponsors_a in similar: + if sponsors_a in dealtwith: + continue + group = set(sponsors_a) + for sponsors_b in similar: + if sponsors_b[0] in group or sponsors_b[1] in group: + group.add(sponsors_b[0]) + group.add(sponsors_b[1]) + dealtwith.append(sponsors_b) + best.append(max(group, key=lambda x:x["votes"])) + for time in best: + times.append(str(time["startTime"]) + "," + str(time["endTime"]) + "," + time["UUID"] + "," + time["category"]) + print(":".join(times)) +elif sys.argv[1] == "update": + try: + urllib.request.urlretrieve(sys.argv[3] + "/database.db", sys.argv[2] + ".tmp") + os.replace(sys.argv[2] + ".tmp", sys.argv[2]) + except PermissionError: + print("database update failed, file currently in use", file=sys.stderr) + sys.exit(1) + except ConnectionResetError: + print("database update failed, connection reset", file=sys.stderr) + sys.exit(1) + except TimeoutError: + print("database update failed, timed out", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError: + print("database update failed", file=sys.stderr) + sys.exit(1) +elif sys.argv[1] == "submit": + try: + req = urllib.request.Request(sys.argv[3] + "/api/skipSegments", data=json.dumps({"videoID": sys.argv[4], "segments": [{"segment": [float(sys.argv[5]), float(sys.argv[6])], "category": sys.argv[9]}], "userID": uid}).encode(), headers={"Content-Type": "application/json"}) + response = urllib.request.urlopen(req) + print("success") + except urllib.error.HTTPError as e: + print(e.code) + except: + print("error") +elif sys.argv[1] == "stats": + try: + if sys.argv[6]: + urllib.request.urlopen(sys.argv[3] + "/api/viewedVideoSponsorTime?UUID=" + sys.argv[5]) + if sys.argv[9]: + urllib.request.urlopen(sys.argv[3] + "/api/voteOnSponsorTime?UUID=" + sys.argv[5] + "&userID=" + uid + "&type=" + sys.argv[9]) + except: + pass +elif sys.argv[1] == "username": + try: + data = urllib.parse.urlencode({"userID": uid, "userName": sys.argv[9]}).encode() + req = urllib.request.Request(sys.argv[3] + "/api/setUsername", data=data) + urllib.request.urlopen(req) + except: + pass diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt new file mode 100644 index 0000000..d2597a5 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt @@ -0,0 +1 @@ +aMn4i07nXmgiLpjJPE235QcbVv3QxdXdH1xB \ No newline at end of file diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/thumbfast.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/thumbfast.lua new file mode 100644 index 0000000..b43d010 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/thumbfast.lua @@ -0,0 +1,515 @@ +-- thumbfast.lua +-- +-- High-performance on-the-fly thumbnailer +-- +-- Built for easy integration in third-party UIs. + +local options = { + -- Socket path (leave empty for auto) + socket = "", + + -- Thumbnail path (leave empty for auto) + thumbnail = "", + + -- Maximum thumbnail size in pixels (scaled down to fit) + -- Values are scaled when hidpi is enabled + max_height = 200, + max_width = 200, + + -- Overlay id + overlay_id = 42, + + -- Thumbnail interval in seconds, set to 0 to disable (warning: high cpu usage) + -- Clamped to min_thumbnails and max_thumbnails + interval = 6, + + -- Number of thumbnails + min_thumbnails = 6, + max_thumbnails = 120, + + -- Spawn thumbnailer on file load for faster initial thumbnails + spawn_first = false, + + -- Enable on network playback + network = false, + + -- Enable on audio playback + audio = false +} + +mp.utils = require "mp.utils" +mp.options = require "mp.options" +mp.options.read_options(options, "thumbfast") + +if options.min_thumbnails < 1 then + options.min_thumbnails = 1 +end + +local os_name = "" + +math.randomseed(os.time()) +local unique = math.random(10000000) +local init = false + +local spawned = false +local can_generate = true +local network = false +local disabled = false +local interval = 0 + +local x = nil +local y = nil +local last_x = x +local last_y = y + +local last_index = nil +local last_request = nil +local last_request_time = nil +local last_display_time = 0 + +local effective_w = options.max_width +local effective_h = options.max_height +local thumb_size = effective_w * effective_h * 4 + +local filters_reset = {["lavfi-crop"]=true, crop=true} +local filters_runtime = {hflip=true, vflip=true} +local filters_all = filters_runtime +for k,v in pairs(filters_reset) do filters_all[k] = v end + +local last_vf_reset = "" +local last_vf_runtime = "" + +local last_rotate = 0 + +local par = "" +local last_par = "" + +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 = mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = {"uname", "-s"}}).stdout + end + end + + raw_os_name = (raw_os_name):lower() + + local os_patterns = { + ["windows"] = "Windows", + + -- Uses socat + ["linux"] = "Linux", + + ["osx"] = "Mac", + ["mac"] = "Mac", + ["darwin"] = "Mac", + + ["^mingw"] = "Windows", + ["^cygwin"] = "Windows", + + -- Because they have the good netcat (with -U) + ["bsd$"] = "Mac", + ["sunos"] = "Mac" + } + + -- 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 function vf_string(filters, full) + local vf = "" + local vf_table = mp.get_property_native("vf") + + if #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 then + vf = vf.."scale=w="..effective_w..":h="..effective_h..par..",pad=w="..effective_w..":h="..effective_h..":x=(ow-iw)/2:y=(oh-ih)/2,format=bgra" + end + + return vf +end + +local function calc_dimensions() + local width = mp.get_property_number("video-out-params/dw") + local height = mp.get_property_number("video-out-params/dh") + if not width or not height then return end + + local scale = mp.get_property_number("display-hidpi-scale", 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 + + thumb_size = effective_w * effective_h * 4 + + local v_par = mp.get_property_number("video-out-params/par", 1) + if v_par == 1 then + par = ":force_original_aspect_ratio=decrease" + else + par = "" + end +end + +local function info() + local display_w, display_h = effective_w, effective_h + if mp.get_property_number("video-params/rotate", 0) % 180 == 90 then + display_w, display_h = effective_h, effective_w + end + + local json, err = mp.utils.format_json({width=display_w, height=display_h, disabled=disabled, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) + mp.commandv("script-message", "thumbfast-info", json) +end + +local function remove_thumbnail_files() + os.remove(options.thumbnail) + os.remove(options.thumbnail..".bgra") +end + +local function spawn(time) + if disabled then return end + + local path = mp.get_property("path") + if path == nil then return end + + spawned = true + + local open_filename = mp.get_property("stream-open-filename") + local ytdl = open_filename and network and path ~= open_filename + if ytdl then + path = open_filename + end + + if os_name == "" then + os_name = get_os() + end + + if options.socket == "" then + if os_name == "Windows" then + options.socket = "thumbfast" + elseif os_name == "Mac" then + options.socket = "/tmp/thumbfast" + else + options.socket = "/tmp/thumbfast" + end + end + + if options.thumbnail == "" then + if os_name == "Windows" then + options.thumbnail = os.getenv("TEMP").."\\thumbfast.out" + elseif os_name == "Mac" then + options.thumbnail = "/tmp/thumbfast.out" + else + options.thumbnail = "/tmp/thumbfast.out" + end + end + + if not init then + -- ensure uniqueness + options.socket = options.socket .. unique + options.thumbnail = options.thumbnail .. unique + init = true + end + + remove_thumbnail_files() + + calc_dimensions() + + info() + + mp.command_native_async( + {name = "subprocess", playback_only = true, args = { + "mpv", path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", + "--edition="..(mp.get_property_number("edition") or "auto"), "--vid="..(mp.get_property_number("vid") or "auto"), "--no-sub", "--no-audio", + "--input-ipc-server="..options.socket, + "--start="..time, "--hr-seek=no", + "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", + "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", + "--vf="..vf_string(filters_all, true), + "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", + "--video-rotate="..last_rotate, + "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail + }}, + function() end + ) +end + +local function run(command, callback) + if not spawned then return end + + callback = callback or function() end + + local seek_command + if os_name == "Windows" then + seek_command = {"cmd", "/c", "echo "..command.." > \\\\.\\pipe\\" .. options.socket} + elseif os_name == "Mac" then + -- this doesn't work, on my system. not sure why. + seek_command = {"/usr/bin/env", "sh", "-c", "echo '"..command.."' | nc -w0 -U " .. options.socket} + else + seek_command = {"/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket} + end + + mp.command_native_async( + {name = "subprocess", playback_only = true, capture_stdout = true, args = seek_command}, + callback + ) +end + +local function thumb_index(thumbtime) + return math.floor(thumbtime / interval) +end + +local function index_time(index, thumbtime) + if interval > 0 then + local time = index * interval + return time + interval / 3 + else + return thumbtime + end +end + +local function draw(w, h, thumbtime, display_time, script) + local display_w, display_h = w, h + if mp.get_property_number("video-params/rotate", 0) % 180 == 90 then + display_w, display_h = h, w + end + + if x ~= nil then + mp.command_native( + {name = "overlay-add", id=options.overlay_id, x=x, y=y, file=options.thumbnail..".bgra", offset=0, fmt="bgra", w=display_w, h=display_h, stride=(4*display_w)} + ) + elseif script then + local json, err = mp.utils.format_json({width=display_w, height=display_h, 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 display_img(w, h, thumbtime, display_time, script, redraw) + if last_display_time > display_time or disabled then return end + + if not redraw then + can_generate = false + + local info = mp.utils.file_info(options.thumbnail) + if not info or info.size ~= thumb_size then + if thumbtime == -1 then + can_generate = true + return + end + + if thumbtime < 0 then + thumbtime = thumbtime + 1 + end + + -- display last successful thumbnail if one exists + local info2 = mp.utils.file_info(options.thumbnail..".bgra") + if info2 and info2.size == thumb_size then + draw(w, h, thumbtime, display_time, script) + end + + -- retry up to 5 times + return mp.add_timeout(0.05, function() display_img(w, h, thumbtime < 0 and thumbtime or -5, display_time, script) end) + end + + if last_display_time > display_time then return end + + -- os.rename can't replace files on windows + if os_name == "Windows" then + os.remove(options.thumbnail..".bgra") + end + -- move the file because it can get overwritten while overlay-add is reading it, and crash the player + os.rename(options.thumbnail, options.thumbnail..".bgra") + + last_display_time = display_time + else + local info = mp.utils.file_info(options.thumbnail..".bgra") + if not info or info.size ~= thumb_size then + -- still waiting on intial thumbnail + return mp.add_timeout(0.05, function() display_img(w, h, thumbtime, display_time, script) end) + end + if not can_generate then + return draw(w, h, thumbtime, display_time, script) + end + end + + draw(w, h, thumbtime, display_time, script) + + can_generate = true + + if not redraw then + -- often, the file we read will be the last requested thumbnail + -- retry after a small delay to ensure we got the latest image + if thumbtime ~= -1 then + mp.add_timeout(0.05, function() display_img(w, h, -1, display_time, script) end) + mp.add_timeout(0.1, function() display_img(w, h, -1, display_time, script) end) + end + end +end + +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 == nil or r_y == nil then + x, y = nil, nil + else + x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5) + end + + local index = thumb_index(time) + local seek_time = index_time(index, time) + + if last_request == seek_time or (interval > 0 and index == last_index) then + last_index = index + if x ~= last_x or y ~= last_y then + last_x, last_y = x, y + display_img(effective_w, effective_h, time, mp.get_time(), script, true) + end + return + end + + local cur_request_time = mp.get_time() + + last_index = index + last_request_time = cur_request_time + last_request = seek_time + + if not spawned then + spawn(seek_time) + if can_generate then + display_img(effective_w, effective_h, time, cur_request_time, script) + mp.add_timeout(0.15, function() display_img(effective_w, effective_h, time, cur_request_time, script) end) + end + return + end + + run("async seek "..seek_time.." absolute+keyframes", function() if can_generate then display_img(effective_w, effective_h, time, cur_request_time, script) end end) +end + +local function clear() + last_display_time = mp.get_time() + can_generate = true + last_x = nil + last_y = nil + mp.command_native( + {name = "overlay-remove", id=options.overlay_id} + ) +end + +local function watch_changes() + local old_w = effective_w + local old_h = effective_h + + calc_dimensions() + + local vf_reset = vf_string(filters_reset) + local rotate = mp.get_property_number("video-rotate", 0) + + if spawned then + if 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 then + last_rotate = rotate + -- mpv doesn't allow us to change output size + run("quit") + clear() + info() + spawned = false + spawn(last_request or mp.get_property_number("time-pos", 0)) + 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 + if 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 then + last_rotate = rotate + info() + end + last_vf_runtime = vf_string(filters_runtime) + end + + last_vf_reset = vf_reset + last_rotate = rotate + last_par = par +end + +local function sync_changes(prop, val) + if spawned and val then + run("set "..prop.." "..val) + end +end + +local function file_load() + clear() + + network = mp.get_property_bool("demuxer-via-network", false) + local image = mp.get_property_native('current-tracks/video/image', true) + local albumart = image and mp.get_property_native("current-tracks/video/albumart", false) + + disabled = (network and not options.network) or (albumart and not options.audio) or (image and not albumart) + info() + if disabled then return end + + interval = math.min(math.max(mp.get_property_number("duration", 1) / options.max_thumbnails, options.interval), mp.get_property_number("duration", options.interval * options.min_thumbnails) / options.min_thumbnails) + + spawned = false + if options.spawn_first then spawn(mp.get_property_number("time-pos", 0)) end +end + +local function shutdown() + run("quit") + remove_thumbnail_files() + os.remove(options.socket) +end + +mp.observe_property("display-hidpi-scale", "native", watch_changes) +mp.observe_property("video-out-params", "native", watch_changes) +mp.observe_property("vf", "native", watch_changes) +mp.observe_property("vid", "native", sync_changes) +mp.observe_property("edition", "native", sync_changes) + +mp.register_script_message("thumb", thumb) +mp.register_script_message("clear", clear) + +mp.register_event("file-loaded", file_load) +mp.register_event("shutdown", shutdown) \ No newline at end of file diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/uosc.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/uosc.lua new file mode 100644 index 0000000..f83888c --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/uosc.lua @@ -0,0 +1,4708 @@ +--[[ uosc 4.0.1 - 2022-Sep-24 | https://github.com/tomasklaen/uosc ]] +local uosc_version = '4.0.1' + +local assdraw = require('mp.assdraw') +local opt = require('mp.options') +local utils = require('mp.utils') +local msg = require('mp.msg') +local osd = mp.create_osd_overlay('ass-events') +local infinity = 1e309 +local quarter_pi_sin = math.sin(math.pi / 4) + +--[[ BASE HELPERS ]] + +---@param number number +function round(number) return math.floor(number + 0.5) end + +---@param min number +---@param value number +---@param max number +function clamp(min, value, max) return math.max(min, math.min(value, max)) end + +---@param rgba string `rrggbb` or `rrggbbaa` hex string. +function serialize_rgba(rgba) + local a = rgba:sub(7, 8) + return { + color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2), + opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1), + } +end + +---@param str string +---@param pattern string +---@return string[] +function split(str, pattern) + local list = {} + local full_pattern = '(.-)' .. pattern + local last_end = 1 + local start_index, end_index, capture = str:find(full_pattern, 1) + while start_index do + list[#list + 1] = capture + last_end = end_index + 1 + start_index, end_index, capture = str:find(full_pattern, last_end) + end + if last_end <= (#str + 1) then + capture = str:sub(last_end) + list[#list + 1] = capture + end + return list +end + +---@param itable table +---@param value any +---@return integer|nil +function itable_index_of(itable, value) + for index, item in ipairs(itable) do + if item == value then return index end + end +end + +---@param itable table +---@param compare fun(value: any, index: number) +---@param from_end? boolean Search from the end of the table. +---@return number|nil index +---@return any|nil value +function itable_find(itable, compare, from_end) + local from, to, step = from_end and #itable or 1, from_end and 1 or #itable, from_end and -1 or 1 + for index = from, to, step do + if compare(itable[index], index) then return index, itable[index] end + end +end + +---@param itable table +---@param decider fun(value: any, index: number) +function itable_filter(itable, decider) + local filtered = {} + for index, value in ipairs(itable) do + if decider(value, index) then filtered[#filtered + 1] = value end + end + return filtered +end + +---@param itable table +---@param value any +function itable_remove(itable, value) + return itable_filter(itable, function(item) return item ~= value end) +end + +---@param itable table +---@param start_pos? integer +---@param end_pos? integer +function itable_slice(itable, start_pos, end_pos) + start_pos = start_pos and start_pos or 1 + end_pos = end_pos and end_pos or #itable + + if end_pos < 0 then end_pos = #itable + end_pos + 1 end + if start_pos < 0 then start_pos = #itable + start_pos + 1 end + + local new_table = {} + for index, value in ipairs(itable) do + if index >= start_pos and index <= end_pos then + new_table[#new_table + 1] = value + end + end + return new_table +end + +---@generic T +---@param a T[]|nil +---@param b T[]|nil +---@return T[] +function itable_join(a, b) + local result = {} + if a then for _, value in ipairs(a) do result[#result + 1] = value end end + if b then for _, value in ipairs(b) do result[#result + 1] = value end end + return result +end + +---@param target any[] +---@param source any[] +function itable_append(target, source) + for _, value in ipairs(source) do target[#target + 1] = value end + return target +end + +---@param target any[] +---@param source any[] +---@param props? string[] +function table_assign(target, source, props) + if props then + for _, name in ipairs(props) do target[name] = source[name] end + else + for prop, value in pairs(source) do target[prop] = value end + end + return target +end + +---@generic T +---@param table T +---@return T +function table_shallow_copy(table) + local result = {} + for key, value in pairs(table) do result[key] = value end + return result +end + +--[[ OPTIONS ]] + +local defaults = { + timeline_style = 'line', + timeline_line_width = 2, + timeline_line_width_fullscreen = 3, + timeline_line_width_minimized_scale = 10, + timeline_size_min = 2, + timeline_size_max = 40, + timeline_size_min_fullscreen = 0, + timeline_size_max_fullscreen = 60, + timeline_start_hidden = false, + timeline_persistency = 'paused', + timeline_opacity = 0.9, + timeline_border = 1, + timeline_step = 5, + timeline_chapters_opacity = 0.8, + + controls = 'menu,gap,subtitles,audio,stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen', + controls_size = 32, + controls_size_fullscreen = 40, + controls_margin = 8, + controls_spacing = 2, + controls_persistency = '', + + volume = 'right', + volume_size = 40, + volume_size_fullscreen = 52, + volume_persistency = '', + volume_opacity = 0.9, + volume_border = 1, + volume_step = 1, + + speed_persistency = '', + speed_opacity = 0.6, + speed_step = 0.1, + speed_step_is_factor = false, + + menu_item_height = 36, + menu_item_height_fullscreen = 50, + menu_min_width = 260, + menu_min_width_fullscreen = 360, + menu_opacity = 1, + menu_parent_opacity = 0.4, + + top_bar = 'no-border', + top_bar_size = 40, + top_bar_size_fullscreen = 46, + top_bar_persistency = '', + top_bar_controls = true, + top_bar_title = true, + top_bar_title_opacity = 0.8, + + window_border_size = 1, + window_border_opacity = 0.8, + + autoload = false, + shuffle = false, + + ui_scale = 1, + font_scale = 1, + text_border = 1.2, + pause_on_click_shorter_than = 0, + flash_duration = 1000, + proximity_in = 40, + proximity_out = 120, + foreground = 'ffffff', + foreground_text = '000000', + background = '000000', + background_text = 'ffffff', + total_time = false, + time_precision = 0, + font_bold = false, + autohide = false, + buffered_time_threshold = 60, + pause_indicator = 'flash', + curtain_opacity = 0.5, + stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144', + media_types = '3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m', + subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt', + font_height_to_letter_width_ratio = 0.5, + default_directory = '~/', + chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80', + chapter_range_patterns = 'openings:オープニング;endings:エンディング', +} +local options = table_shallow_copy(defaults) +opt.read_options(options, 'uosc') +-- Normalize values +options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) +options.foreground = serialize_rgba(options.foreground).color +options.foreground_text = serialize_rgba(options.foreground_text).color +options.background = serialize_rgba(options.background).color +options.background_text = serialize_rgba(options.background_text).color +if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end +-- Ensure required environment configuration +if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end + +--[[ CONFIG ]] + +local config = { + version = uosc_version, + -- sets max rendering frequency in case the + -- native rendering frequency could not be detected + render_delay = 1 / 60, + font = mp.get_property('options/osd-font'), + media_types = split(options.media_types, ' *, *'), + subtitle_types = split(options.subtitle_types, ' *, *'), + stream_quality_options = split(options.stream_quality_options, ' *, *'), + menu_items = (function() + local input_conf_property = mp.get_property_native('input-conf'); + local input_conf_path = mp.command_native({ + 'expand-path', input_conf_property == '' and '~~/input.conf' or input_conf_property, + }) + local input_conf_meta, meta_error = utils.file_info(input_conf_path) + + -- File doesn't exist + if not input_conf_meta or not input_conf_meta.is_file then return end + + local main_menu = {items = {}, items_by_command = {}} + local by_id = {} + + for line in io.lines(input_conf_path) do + local key, command, title = string.match(line, '%s*([%S]+)%s+(.-)%s+#!%s*(.-)%s*$') + if not key then + key, command, title = string.match(line, '%s*([%S]+)%s+(.-)%s+#menu:%s*(.-)%s*$') + end + if key then + local is_dummy = key:sub(1, 1) == '#' + local submenu_id = '' + local target_menu = main_menu + local title_parts = split(title or '', ' *> *') + + for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do + if index < #title_parts then + submenu_id = submenu_id .. title_part + + if not by_id[submenu_id] then + local items = {} + by_id[submenu_id] = {items = items, items_by_command = {}} + target_menu.items[#target_menu.items + 1] = {title = title_part, items = items} + end + + target_menu = by_id[submenu_id] + else + if command == 'ignore' then break end + -- If command is already in menu, just append the key to it + if target_menu.items_by_command[command] then + local hint = target_menu.items_by_command[command].hint + target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key + else + local item = { + title = title_part, + hint = not is_dummy and key or nil, + value = command, + } + target_menu.items_by_command[command] = item + target_menu.items[#target_menu.items + 1] = item + end + end + end + end + end + + if #main_menu.items > 0 then + return main_menu.items + else + -- Default context menu + return { + {title = 'Open file', value = 'script-binding uosc/open-file'}, + {title = 'Playlist', value = 'script-binding uosc/playlist'}, + {title = 'Chapters', value = 'script-binding uosc/chapters'}, + {title = 'Subtitle tracks', value = 'script-binding uosc/subtitles'}, + {title = 'Audio tracks', value = 'script-binding uosc/audio'}, + {title = 'Stream quality', value = 'script-binding uosc/stream-quality'}, + {title = 'Navigation', items = { + {title = 'Next', hint = 'playlist or file', value = 'script-binding uosc/next'}, + {title = 'Prev', hint = 'playlist or file', value = 'script-binding uosc/prev'}, + {title = 'Delete file & Next', value = 'script-binding uosc/delete-file-next'}, + {title = 'Delete file & Prev', value = 'script-binding uosc/delete-file-prev'}, + {title = 'Delete file & Quit', value = 'script-binding uosc/delete-file-quit'}, + },}, + {title = 'Utils', items = { + {title = 'Load subtitles', value = 'script-binding uosc/load-subtitles'}, + {title = 'Aspect ratio', items = { + {title = 'Default', value = 'set video-aspect-override "-1"'}, + {title = '16:9', value = 'set video-aspect-override "16:9"'}, + {title = '4:3', value = 'set video-aspect-override "4:3"'}, + {title = '2.35:1', value = 'set video-aspect-override "2.35:1"'}, + },}, + {title = 'Audio devices', value = 'script-binding uosc/audio-device'}, + {title = 'Screenshot', value = 'async screenshot'}, + {title = 'Show in directory', value = 'script-binding uosc/show-in-directory'}, + {title = 'Open config folder', value = 'script-binding uosc/open-config-directory'}, + },}, + {title = 'Quit', value = 'quit'}, + } + end + end)(), + chapter_ranges = (function() + ---@type table Alternative patterns. + local alt_patterns = {} + if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then + for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do + local name_patterns = split(definition, ' *:') + local name, patterns = name_patterns[1], name_patterns[2] + if name and patterns then alt_patterns[name] = split(patterns, ',') end + end + end + + ---@type table + local ranges = {} + if options.chapter_ranges and options.chapter_ranges ~= '' then + for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do + local name_color = split(definition, ' *:+ *') + local name, color = name_color[1], name_color[2] + if name and color + and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$') + and (#color == 6 or #color == 8) then + local range = serialize_rgba(name_color[2]) + range.patterns = alt_patterns[name] + ranges[name_color[1]] = range + end + end + end + return ranges + end)(), +} +-- Adds `{element}_persistency` property with table of flags when the element should be visible (`{paused = true}`) +for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do + local option_name = name .. '_persistency' + local value, flags = options[option_name], {} + if type(value) == 'string' then + for _, state in ipairs(split(value, ' *, *')) do flags[state] = true end + end + config[option_name] = flags +end + +--[[ STATE ]] + +local display = {width = 1280, height = 720, scale_x = 1, scale_y = 1} +local cursor = {hidden = true, x = 0, y = 0} +local state = { + os = (function() + if os.getenv('windir') ~= nil then return 'windows' end + local homedir = os.getenv('HOME') + if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'macos' end + return 'linux' + end)(), + cwd = mp.get_property('working-directory'), + path = nil, -- current file path or URL + title = nil, + time = nil, -- current media playback time + speed = 1, + duration = nil, -- current media duration + time_human = nil, -- current playback time in human format + duration_or_remaining_time_human = nil, -- depends on options.total_time + pause = mp.get_property_native('pause'), + chapters = {}, + current_chapter = nil, + chapter_ranges = {}, + border = mp.get_property_native('border'), + fullscreen = mp.get_property_native('fullscreen'), + maximized = mp.get_property_native('window-maximized'), + fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'), + render_timer = nil, + render_last_time = 0, + volume = nil, + volume_max = nil, + mute = nil, + is_video = false, + is_audio = false, -- true if file is audio only (mp3, etc) + is_image = false, + is_stream = false, + has_audio = false, + has_sub = false, + has_chapter = false, + has_playlist = false, + shuffle = options.shuffle, + cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function() + if not options.autohide then return end + handle_mouse_leave() + end), + mouse_bindings_enabled = false, + uncached_ranges = nil, + cache = nil, + render_delay = config.render_delay, + first_real_mouse_move_received = false, + playlist_count = 0, + playlist_pos = 0, + margin_top = 0, + margin_bottom = 0, + hidpi_scale = 1, +} +local thumbnail = {width = 0, height = 0, disabled = false} + +--[[ HELPERS ]] + +-- Sorting comparator close to (but not exactly) how file explorers sort files +local file_order_comparator = (function() + local symbol_order + local default_order + + if state.os == 'win' then + symbol_order = { + ['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7, + ['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14, + ['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20, + } + default_order = 21 + else + symbol_order = { + ['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8, + ['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14, + ['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23, + } + default_order = 21 + end + + ---@param a string|number + ---@param b string|number + ---@return boolean + return function(a, b) + a = a:lower() + b = b:lower() + for i = 1, math.max(#a, #b) do + local ai = a:sub(i, i) + local bi = b:sub(i, i) + if ai == nil and bi then return true end + if bi == nil and ai then return false end + local a_order = symbol_order[ai] or default_order + local b_order = symbol_order[bi] or default_order + if a_order == b_order then + return a < b + else + return a_order < b_order + end + end + end +end)() + +-- Creates in-between frames to animate value from `from` to `to` numbers. +---@param from number +---@param to number|fun():number +---@param setter fun(value: number) +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function tween(from, to, setter, factor_or_callback, callback) + local factor = factor_or_callback + if type(factor_or_callback) == 'function' then callback = factor_or_callback end + if type(factor) ~= 'number' then factor = 0.3 end + + local current, done, timeout = from, false, nil + local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end + local cutoff = math.abs(get_to() - from) * 0.01 + + local function finish() + if not done then + done = true + timeout:kill() + if callback then callback() end + end + end + + local function tick() + local to = get_to() + current = current + ((to - current) * factor) + local is_end = math.abs(to - current) <= cutoff + setter(is_end and to or current) + request_render() + if is_end then finish() + else timeout:resume() end + end + + timeout = mp.add_timeout(state.render_delay, tick) + tick() + + return finish +end + +---@param point {x: number; y: number} +---@param rect {ax: number; ay: number; bx: number; by: number} +function get_point_to_rectangle_proximity(point, rect) + local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx) + local dy = math.max(rect.ay - point.y, 0, point.y - rect.by) + return math.sqrt(dx * dx + dy * dy); +end + +---@param text string|number +---@param font_size number +function text_width_estimate(text, font_size) return text_length_width_estimate(text_length(text), font_size) end + +---@param length number +---@param font_size number +function text_length_width_estimate(length, font_size) + return length * font_size * options.font_height_to_letter_width_ratio +end + +---@param text string|number +function text_length(text) + if not text or text == '' then return 0 end + local text_length = 0 + for _, _, length in utf8_iter(tostring(text)) do text_length = text_length + length end + return text_length +end + +function utf8_iter(string) + local byte_start, byte_count = 1, 1 + + return function() + if #string < byte_start then return nil end + + local char_byte = string.byte(string, byte_start) + + byte_count = 1; + if char_byte < 192 then byte_count = 1 + elseif char_byte < 224 then byte_count = 2 + elseif char_byte < 240 then byte_count = 3 + elseif char_byte < 248 then byte_count = 4 + elseif char_byte < 252 then byte_count = 5 + elseif char_byte < 254 then byte_count = 6 + end + + local start = byte_start + byte_start = byte_start + byte_count + + return start, byte_count, (byte_count > 2 and 2 or 1) + end +end + +function wrap_text(text, target_line_length) + local line_length = 0 + local wrap_at_chars = {' ', ' ', '-', '–'} + local remove_when_wrap = {' ', ' '} + local lines = {} + local line_start = 1 + local before_end = nil + local before_length = 0 + local before_line_start = 0 + local before_removed_length = 0 + local max_length = 0 + for char_start, count, char_length in utf8_iter(text) do + local char_end = char_start + count - 1 + local char = text.sub(text, char_start, char_end) + local can_wrap = false + for _, c in ipairs(wrap_at_chars) do + if char == c then + can_wrap = true + break + end + end + line_length = line_length + char_length + if can_wrap or (char_end == #text) then + local remove = false + for _, c in ipairs(remove_when_wrap) do + if char == c then + remove = true + break + end + end + local line_length_after_remove = line_length - (remove and char_length or 0) + if line_length_after_remove < target_line_length then + before_end = remove and char_start - 1 or char_end + before_length = line_length_after_remove + before_line_start = char_end + 1 + before_removed_length = remove and char_length or 0 + else + if (target_line_length - before_length) < + (line_length_after_remove - target_line_length) then + lines[#lines + 1] = text.sub(text, line_start, before_end) + line_start = before_line_start + line_length = line_length - before_length - before_removed_length + if before_length > max_length then max_length = before_length end + else + lines[#lines + 1] = text.sub(text, line_start, remove and char_start - 1 or char_end) + line_start = char_end + 1 + line_length = remove and line_length - char_length or line_length + if line_length > max_length then max_length = line_length end + line_length = 0 + end + before_end = line_start + before_length = 0 + end + end + end + if #text >= line_start then + lines[#lines + 1] = string.sub(text, line_start) + if line_length > max_length then max_length = line_length end + end + return table.concat(lines, '\n'), max_length, #lines +end + +-- Escape a string for verbatim display on the OSD +---@param str string +function ass_escape(str) + -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if + -- it isn't followed by a recognized 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 + +---@param seconds number +---@return string +function format_time(seconds) + local human = mp.format_time(seconds) + if options.time_precision > 0 then + local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1) + human = human .. '.' .. string.sub(formatted, 3) + end + return human +end + +---@param opacity number 0-1 +function opacity_to_alpha(opacity) + return 255 - math.ceil(255 * opacity) +end + +-- Ensures path is absolute and normalizes slashes to the current platform +---@param path string +function normalize_path(path) + if not path or is_protocol(path) then return path end + + -- Ensure path is absolute + if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then + path = utils.join_path(state.cwd, path) + end + + -- Remove trailing slashes + if #path > 1 then + path = path:gsub('[\\/]+$', '') + path = #path == 0 and '/' or path + end + + -- Use proper slashes + if state.os == 'windows' then + -- Drive letters on windows need trailing backslash + if path:sub(#path) == ':' then + path = path .. '\\' + end + + return path:gsub('/', '\\') + else + return path:gsub('\\', '/') + end +end + +-- Check if path is a protocol, such as `http://...` +---@param path string +function is_protocol(path) + return type(path) == 'string' and path:match('^%a[%a%d-_]+://') ~= nil +end + +---@param path string +function get_extension(path) + local parts = split(path, '%.') + return parts and #parts > 1 and parts[#parts] or nil +end + +---@return string +function get_default_directory() + return mp.command_native({'expand-path', options.default_directory}) +end + +-- Serializes path into its semantic parts +---@param path string +---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;} +function serialize_path(path) + if not path or is_protocol(path) then return end + + local normal_path = normalize_path(path) + -- normalize_path() already strips slashes, but leaves trailing backslash + -- for windows drive letters, but we don't need it here. + local working_path = normal_path:sub(#normal_path) == '\\' and normal_path:sub(1, #normal_path - 1) or normal_path + local parts = split(working_path, '[\\/]+') + local basename = parts and parts[#parts] or working_path + local dirname = #parts > 1 + and table.concat(itable_slice(parts, 1, #parts - 1), state.os == 'windows' and '\\' or '/') + or nil + local dot_split = split(basename, '%.') + + return { + path = normal_path, + is_root = dirname == nil, + dirname = dirname, + basename = basename, + filename = #dot_split > 1 and table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or basename, + extension = #dot_split > 1 and dot_split[#dot_split] or nil, + } +end + +---@param directory string +---@param allowed_types? string[] +---@return nil|string[] +function get_files_in_directory(directory, allowed_types) + local files, error = utils.readdir(directory, 'files') + + if not files then + msg.error('Retrieving files failed: ' .. (error or '')) + return + end + + -- Filter only requested file types + if allowed_types then + files = itable_filter(files, function(file) + local extension = get_extension(file) + return extension and itable_index_of(allowed_types, extension:lower()) + end) + end + + table.sort(files, file_order_comparator) + + return files +end + +-- Returns full absolute paths of files in the same directory as file_path, +-- and index of the current file in the table. +---@param file_path string +---@param allowed_types? string[] +function get_adjacent_paths(file_path, allowed_types) + local current_file = serialize_path(file_path) + if not current_file then return end + local files = get_files_in_directory(current_file.dirname, allowed_types) + if not files then return end + local current_file_index + local paths = {} + for index, file in ipairs(files) do + paths[#paths + 1] = utils.join_path(current_file.dirname, file) + if current_file.basename == file then current_file_index = index end + end + if not current_file_index then return end + return paths, current_file_index +end + +-- Navigates in a list, using delta or, when `state.shuffle` is enabled, +-- randomness to determine the next item. Loops around if `loop-playlist` is enabled. +---@param list table +---@param current_index number +---@param delta number +function decide_navigation_in_list(list, current_index, delta) + if #list < 2 then return #list, list[#list] end + + if state.shuffle then + local new_index = current_index + while current_index == new_index do new_index = math.random(#list) end + return new_index, list[new_index] + end + + local new_index = current_index + delta + if mp.get_property_native('loop-playlist') then + if new_index > #list then new_index = new_index % #list + elseif new_index < 1 then new_index = #list - new_index end + elseif new_index < 1 or new_index > #list then + return + end + + return new_index, list[new_index] +end + +---@param delta number +function navigate_directory(delta) + if not state.path or is_protocol(state.path) then return false end + local paths, current_index = get_adjacent_paths(state.path, config.media_types) + if paths and current_index then + local _, path = decide_navigation_in_list(paths, current_index, delta) + if path then mp.commandv('loadfile', path) return true end + end + return false +end + +---@param delta number +function navigate_playlist(delta) + local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1') + if playlist and #playlist > 1 and pos then + local index = decide_navigation_in_list(playlist, pos, delta) + if index then mp.commandv('playlist-play-index', index - 1) return true end + end + return false +end + +---@param delta number +function navigate_item(delta) + if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end +end + +-- Can't use `os.remove()` as it fails on paths with unicode characters. +-- Returns `result, error`, result is table of: +-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean` +---@param path string +function delete_file(path) + local args = state.os == 'windows' and {'cmd', '/C', 'del', path} or {'rm', path} + return mp.command_native({ + name = 'subprocess', + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) +end + +function serialize_chapter_ranges(normalized_chapters) + local ranges = {} + local simple_ranges = { + {name = 'openings', patterns = {'^op ', '^op$', ' op$', 'opening$'}, requires_next_chapter = true}, + {name = 'intros', patterns = {'^intro$'}, requires_next_chapter = true}, + {name = 'endings', patterns = {'^ed ', '^ed$', ' ed$', 'ending$'}}, + {name = 'outros', patterns = {'^outro$'}}, + } + local sponsor_ranges = {} + + -- Extend with alt patterns + for _, meta in ipairs(simple_ranges) do + local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns + if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end + end + + -- Clone chapters + local chapters = {} + for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end + + for i, chapter in ipairs(chapters) do + -- Simple ranges + for _, meta in ipairs(simple_ranges) do + if config.chapter_ranges[meta.name] then + local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end) + if match then + local next_chapter = chapters[i + 1] + if next_chapter or not meta.requires_next_chapter then + ranges[#ranges + 1] = table_assign({ + start = chapter.time, + ['end'] = next_chapter and next_chapter.time or infinity, + }, config.chapter_ranges[meta.name]) + chapter.is_range_point = true + if next_chapter then next_chapter.is_range_point = true end + end + end + end + end + + -- Sponsor blocks + if config.chapter_ranges.ads then + local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)') + if id then + for j = i + 1, #chapters, 1 do + local end_chapter = chapters[j] + local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)') + if end_match then + local range = table_assign({ + start_chapter = chapter, end_chapter = end_chapter, + start = chapter.time, ['end'] = end_chapter.time, + }, config.chapter_ranges.ads) + ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range + chapter.is_range_point, end_chapter.is_range_point, end_chapter.is_end_only = true, true, true + break + end + end + end + end + end + + -- Fix overlapping sponsor block segments + for index, range in ipairs(sponsor_ranges) do + local next_range = sponsor_ranges[index + 1] + if next_range then + local delta = next_range.start - range['end'] + if delta < 0 then + local mid_point = range['end'] + delta / 2 + range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01 + next_range.start, next_range.start_chapter.time = mid_point, mid_point + end + end + end + table.sort(chapters, function(a, b) return a.time < b.time end) + + return chapters, ranges +end + +-- Ensures chapters are in chronological order +function normalize_chapters(chapters) + if not chapters then return {} end + -- Ensure chronological order + table.sort(chapters, function(a, b) return a.time < b.time end) + -- Ensure titles + for index, chapter in ipairs(chapters) do + chapter.title = chapter.title or ('Chapter ' .. index) + chapter.lowercase_title = chapter.title:lower() + end + return chapters +end + +function serialize_chapters(chapters) + chapters = normalize_chapters(chapters) + if not chapters then return end + for index, chapter in ipairs(chapters) do + chapter.index = index + chapter.title_wrapped, chapter.title_wrapped_width, chapter.title_wrapped_lines = wrap_text(chapter.title, 25) + chapter.title_wrapped = ass_escape(chapter.title_wrapped) + end + return chapters +end + +--[[ ASSDRAW EXTENSIONS ]] + +local ass_mt = getmetatable(assdraw.ass_new()) + +-- Opacity +---@param opacity number|number[] Opacity of all elements, or an array of [primary, secondary, border, shadow] opacities. +---@param fraction? number Optionally adjust the above opacity by this fraction. +function ass_mt:opacity(opacity, fraction) + fraction = fraction ~= nil and fraction or 1 + if type(opacity) == 'number' then + self.text = self.text .. string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction)) + else + self.text = self.text .. string.format( + '{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}', + opacity_to_alpha((opacity[1] or 0) * fraction), + opacity_to_alpha((opacity[2] or 0) * fraction), + opacity_to_alpha((opacity[3] or 0) * fraction), + opacity_to_alpha((opacity[4] or 0) * fraction) + ) + end +end + +-- Icon +---@param x number +---@param y number +---@param size number +---@param name string +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number} +function ass_mt:icon(x, y, size, name, opts) + opts = opts or {} + opts.size = size + opts.font = 'MaterialIconsRound-Regular' + self:txt(x, y, opts.align or 5, name, opts) +end + +-- Text +-- Named `txt` because `ass.text` is a value. +---@param x number +---@param y number +---@param align number +---@param value string|number +---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; wrap?: number; opacity?: number; clip?: string} +function ass_mt:txt(x, y, align, value, opts) + local border_size = opts.border or 0 + local shadow_size = opts.shadow or 0 + local tags = '\\pos(' .. x .. ',' .. y .. ')\\an' .. align .. '\\blur0' + -- font + tags = tags .. '\\fn' .. (opts.font or config.font) + -- font size + tags = tags .. '\\fs' .. opts.size + -- bold + if opts.bold or options.font_bold then tags = tags .. '\\b1' end + -- italic + if opts.italic then tags = tags .. '\\i1' end + -- wrap + if opts.wrap then tags = tags .. '\\q' .. opts.wrap end + -- border + tags = tags .. '\\bord' .. border_size + -- shadow + tags = tags .. '\\shad' .. shadow_size + -- colors + tags = tags .. '\\1c&H' .. (opts.color or options.foreground) + if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or options.background) end + if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or options.background) end + -- opacity + if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end + -- clip + if opts.clip then tags = tags .. opts.clip end + -- render + self:new_event() + self.text = self.text .. '{' .. tags .. '}' .. value +end + +-- Tooltip +---@param element {ax: number; ay: number; bx: number; by: number} +---@param value string|number +---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; text_length_override?: number; responsive?: boolean} +function ass_mt:tooltip(element, value, opts) + opts = opts or {} + opts.size = opts.size or 16 + opts.border = options.text_border + opts.border_color = options.background + local offset = opts.offset or opts.size / 2 + local align_top = opts.responsive == false or element.ay - offset > opts.size * 2 + local x = element.ax + (element.bx - element.ax) / 2 + local y = align_top and element.ay - offset or element.by + offset + local text_width = opts.text_length_override + and opts.text_length_override * opts.size * options.font_height_to_letter_width_ratio + or text_width_estimate(value, opts.size) + local margin = text_width / 2 + self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts) +end + +-- Rectangle +---@param ax number +---@param ay number +---@param bx number +---@param by number +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string, radius?: number} +function ass_mt:rect(ax, ay, bx, by, opts) + opts = opts or {} + local border_size = opts.border or 0 + local tags = '\\pos(0,0)\\blur0' + -- border + tags = tags .. '\\bord' .. border_size + -- colors + tags = tags .. '\\1c&H' .. (opts.color or options.foreground) + if border_size > 0 then + tags = tags .. '\\3c&H' .. (opts.border_color or options.background) + end + -- opacity + if opts.opacity then + tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) + end + -- clip + if opts.clip then + tags = tags .. opts.clip + end + -- draw + self:new_event() + self.text = self.text .. '{' .. tags .. '}' + self:draw_start() + if opts.radius then + self:round_rect_cw(ax, ay, bx, by, opts.radius) + else + self:rect_cw(ax, ay, bx, by) + end + self:draw_stop() +end + +-- Circle +---@param x number +---@param y number +---@param radius number +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string} +function ass_mt:circle(x, y, radius, opts) + opts = opts or {} + opts.radius = radius + self:rect(x - radius, y - radius, x + radius, y + radius, opts) +end + +-- Texture +---@param ax number +---@param ay number +---@param bx number +---@param by number +---@param char string Texture font character. +---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number} +function ass_mt:texture(ax, ay, bx, by, char, opts) + opts = opts or {} + local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay + local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')') + local tile_size, opacity = opts.size or 100, opts.opacity or 0.2 + local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size + local width, height = bx - x, by - y + local line = string.rep(char, math.ceil((width / tile_size))) + local lines = '' + for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end + self:txt( + x, y, 7, lines, + {font = 'uosc_textures', size = tile_size, color = opts.color, opacity = opacity, clip = clip}) +end + +--[[ ELEMENTS COLLECTION ]] + +local Elements = {itable = {}} + +---@param element Element +function Elements:add(element) + if not element.id then + msg.error('attempt to add element without "id" property') + return + end + + if self:has(element.id) then Elements:remove(element.id) end + + self.itable[#self.itable + 1] = element + self[element.id] = element + + request_render() +end + +function Elements:remove(idOrElement) + if not idOrElement then return end + local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement + local element = Elements[id] + if element then + if not element.destroyed then element:destroy() end + element.enabled = false + self.itable = itable_remove(self.itable, self[id]) + self[id] = nil + request_render() + end +end + +function Elements:update_proximities() + local capture_mbtn_left = false + local capture_wheel = false + local menu_only = Elements.menu ~= nil + local mouse_leave_elements = {} + local mouse_enter_elements = {} + + -- Calculates proximities and opacities for defined elements + for _, element in self:ipairs() do + if element.enabled then + local previous_proximity_raw = element.proximity_raw + + -- If menu is open, all other elements have to be disabled + if menu_only then + if element.ignores_menu then + capture_mbtn_left = true + capture_wheel = true + element:update_proximity() + else + element.proximity_raw = infinity + element.proximity = 0 + end + else + element:update_proximity() + end + + -- Element has global forced key listeners + if element.on_global_mbtn_left_down then capture_mbtn_left = true end + if element.on_global_wheel_up or element.on_global_wheel_down then capture_wheel = true end + + if element.proximity_raw == 0 then + -- Element has local forced key listeners + if element.on_mbtn_left_down then capture_mbtn_left = true end + if element.on_wheel_up or element.on_wheel_up then capture_wheel = true end + + -- Mouse entered element area + if previous_proximity_raw ~= 0 then + mouse_enter_elements[#mouse_enter_elements + 1] = element + end + else + -- Mouse left element area + if previous_proximity_raw == 0 then + mouse_leave_elements[#mouse_leave_elements + 1] = element + end + end + end + end + + -- Enable key group captures requested by elements + mp[capture_mbtn_left and 'enable_key_bindings' or 'disable_key_bindings']('mbtn_left') + mp[capture_wheel and 'enable_key_bindings' or 'disable_key_bindings']('wheel') + + -- Trigger `mouse_leave` and `mouse_enter` events + for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end + for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end +end + +-- Toggles passed elements' min visibilities between 0 and 1. +---@param ids string[] IDs of elements to peek. +function Elements:toggle(ids) + local elements = itable_filter(self.itable, function(element) return itable_index_of(ids, element.id) ~= nil end) + local all_visible = itable_find(elements, function(element) return element.min_visibility ~= 1 end) == nil + local to = all_visible and 0 or 1 + for _, element in ipairs(elements) do element:tween_property('min_visibility', element.min_visibility, to) end +end + +---@param name string Event name. +function Elements:trigger(name, ...) + for _, element in self:ipairs() do element:trigger(name, ...) end +end + +-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity. +-- Disabled elements don't receive these events. +---@param name string Event name. +function Elements:proximity_trigger(name, ...) + for _, element in self:ipairs() do + if element.enabled then + if element.proximity_raw == 0 then element:trigger(name, ...) end + element:trigger('global_' .. name, ...) + end + end +end + +function Elements:has(id) return self[id] ~= nil end +function Elements:ipairs() return ipairs(self.itable) end + +---@param name string Event name. +function Elements:create_proximity_dispatcher(name) + return function(...) self:proximity_trigger(name, ...) end +end + +mp.set_key_bindings({ + { + 'mbtn_left', + Elements:create_proximity_dispatcher('mbtn_left_up'), + Elements:create_proximity_dispatcher('mbtn_left_down'), + }, + {'mbtn_left_dbl', 'ignore'}, +}, 'mbtn_left', 'force') + +mp.set_key_bindings({ + {'wheel_up', Elements:create_proximity_dispatcher('wheel_up')}, + {'wheel_down', Elements:create_proximity_dispatcher('wheel_down')}, +}, 'wheel', 'force') + +--[[ STATE UPDATES ]] + +function update_display_dimensions() + local scale = (state.hidpi_scale or 1) * options.ui_scale + local real_width, real_height = mp.get_osd_size() + local scaled_width, scaled_height = round(real_width / scale), round(real_height / scale) + display.width, display.height = scaled_width, scaled_height + display.scale_x, display.scale_y = real_width / scaled_width, real_height / scaled_height + + -- Tell elements about this + Elements:trigger('display') + + -- Some elements probably changed their rectangles as a reaction to `display` + Elements:update_proximities() + request_render() +end + +function update_fullormaxed() + state.fullormaxed = state.fullscreen or state.maximized + update_display_dimensions() + Elements:trigger('prop_fullormaxed', state.fullormaxed) +end + +function update_human_times() + if state.time then + state.time_human = format_time(state.time) + if state.duration then + local speed = state.speed or 1 + state.duration_or_remaining_time_human = format_time( + options.total_time and state.duration or ((state.time - state.duration) / speed) + ) + else + state.duration_or_remaining_time_human = nil + end + else + state.time_human = nil + end +end + +-- Notifies other scripts such as console about where the unoccupied parts of the screen are. +function update_margins() + -- margins are normalized to window size + local timeline, top_bar, controls = Elements.timeline, Elements.top_bar, Elements.controls + local bottom_y = controls and controls.enabled and controls.ay or timeline.ay + local top, bottom = 0, (display.height - bottom_y) / display.height + + if top_bar.enabled and top_bar:get_visibility() > 0 then + top = (top_bar.size or 0) / display.height + end + + if top == state.margin_top and bottom == state.margin_bottom then return end + + state.margin_top = top + state.margin_bottom = bottom + + utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom)) +end + +--[[ RENDERING ]] + +-- Request that render() is called. +-- The render is then either executed immediately, or rate-limited if it was +-- called a small time ago. +function request_render() + if state.render_timer == nil then + state.render_timer = mp.add_timeout(0, render) + end + + if not state.render_timer:is_enabled() then + local now = mp.get_time() + local timeout = state.render_delay - (now - state.render_last_time) + if timeout < 0 then + timeout = 0 + end + state.render_timer.timeout = timeout + state.render_timer:resume() + end +end + +function render() + state.render_last_time = mp.get_time() + + -- Actual rendering + local ass = assdraw.ass_new() + + for _, element in Elements:ipairs() do + if element.enabled then + local result = element:maybe('render') + if result then + ass:new_event() + ass:merge(result) + end + end + end + + -- submit + if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then + return + end + + osd.res_x = display.width + osd.res_y = display.height + osd.data = ass.text + osd.z = 2000 + osd:update() + + update_margins() +end + +--[[ CLASSES ]] + +---@class Class +local Class = {} +function Class:new(...) + local object = setmetatable({}, {__index = self}) + object:init(...) + return object +end +function Class:init() end +function Class:destroy() end + +function class(parent) return setmetatable({}, {__index = parent or Class}) end + +--[[ ELEMENT ]] + +---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;} + +-- Base class all elements inherit from. +---@class Element : Class +local Element = class() + +---@param id string +---@param props? ElementProps +function Element:init(id, props) + self.id = id + -- `false` means element won't be rendered, or receive events + self.enabled = true + -- Element coordinates + self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0 + -- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range. + self.proximity = 0 + -- Raw proximity in pixels. + self.proximity_raw = infinity + ---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility. + self.min_visibility = 0 + ---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations + self.forced_visibility = nil + ---@type boolean Render this element even when menu is open. + self.ignores_menu = false + ---@type nil|string ID of an element from which this one should inherit visibility. + self.anchor_id = nil + + if props then table_assign(self, props) end + + -- Flash timer + self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function() + local getTo = function() return self.proximity end + self:tween_property('forced_visibility', 1, getTo, function() + self.forced_visibility = nil + end) + end) + self._flash_out_timer:kill() + + Elements:add(self) +end + +function Element:destroy() + self.destroyed = true + Elements:remove(self) +end + +---@param ax number +---@param ay number +---@param bx number +---@param by number +function Element:set_coordinates(ax, ay, bx, by) + self.ax, self.ay, self.bx, self.by = ax, ay, bx, by + Elements:update_proximities() + self:maybe('on_coordinates') +end + +function Element:update_proximity() + if cursor.hidden then + self.proximity_raw = infinity + self.proximity = 0 + else + local range = options.proximity_out - options.proximity_in + self.proximity_raw = get_point_to_rectangle_proximity(cursor, self) + self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range) + end +end + +-- Decide elements visibility based on proximity and various other factors +function Element:get_visibility() + -- Hide when menu is open, unless this is a menu + ---@diagnostic disable-next-line: undefined-global + if not self.ignores_menu and Menu and Menu:is_open() then return 0 end + + -- Persistency + local persist = config[self.id .. '_persistency']; + if persist and ( + (persist.audio and state.is_audio) + or (persist.paused and state.pause) + or (persist.video and state.is_video) + or (persist.image and state.is_image) + ) then return 1 end + + -- Forced visibility + if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end + + -- Anchor inheritance + -- If anchor returns -1, it means all attached elements should force hide. + local anchor = self.anchor_id and Elements[self.anchor_id] + local anchor_visibility = anchor and anchor:get_visibility() or 0 + + return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility) +end + +-- Call method if it exists +function Element:maybe(name, ...) + if self[name] then return self[name](self, ...) end +end + +-- Attach a tweening animation to this element +---@param from number +---@param to number|fun():number +---@param setter fun(value: number) +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function Element:tween(from, to, setter, factor_or_callback, callback) + self:tween_stop() + self._kill_tween = self.enabled and tween( + from, to, setter, factor_or_callback, + function() + self._kill_tween = nil + if callback then callback() end + end + ) +end + +function Element:is_tweening() return self and self._kill_tween end +function Element:tween_stop() self:maybe('_kill_tween') end + +-- Animate an element property between 2 values. +---@param prop string +---@param from number +---@param to number|fun():number +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function Element:tween_property(prop, from, to, factor_or_callback, callback) + self:tween(from, to, function(value) self[prop] = value end, factor_or_callback, callback) +end + +---@param name string +function Element:trigger(name, ...) + self:maybe('on_' .. name, ...) + request_render() +end + +-- Briefly flashes the element for `options.flash_duration` milliseconds. +-- Useful to visualize changes of volume and timeline when changed via hotkeys. +function Element:flash() + if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then + self:tween_stop() + self.forced_visibility = 1 + self._flash_out_timer:kill() + self._flash_out_timer:resume() + end +end + +--[[ MENU ]] +--[[ +Usage: +``` +local data = { + type = 'foo', + title = 'Foo', + items = { + {title = 'Foo title', hint = 'Ctrl+F', value = 'foo'}, + {title = 'Submenu', items = {...}} + } +} + +function open_item(value) + -- do something with value +end + +local menu = Menu:open(items, open_item) +menu.update(new_data) +menu.update_items(new_items) +menu.close() +``` +]] + +-- Menu data structure accepted by `Menu:open(menu)`. +---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected_index?: integer;} +---@alias MenuDataItem MenuDataValue|MenuData +---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; keep_open?: boolean; separator?: boolean;} +---@alias MenuOptions {blurred?: boolean; on_open?: fun(), on_close?: fun()} + +-- Internal data structure created from `Menu`. +---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_length: number; title_width: number; hint_length: number; hint_width: number; max_width: number; is_root?: boolean;} +---@alias MenuStackItem MenuStackValue|MenuStack +---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_length: number; title_width: number; hint_length: number; hint_width: number} + +---@class Menu : Element +local Menu = class(Element) + +---@param data MenuData +---@param callback fun(value: any) +---@param opts? MenuOptions +function Menu:open(data, callback, opts) + local open_menu = self:is_open() + if open_menu then + open_menu.is_being_replaced = true + open_menu:close(true) + end + return Menu:new(data, callback, opts) +end + +---@param menu_type? string +---@return Menu|nil +function Menu:is_open(menu_type) + return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil +end + +---@param immediate? boolean Close immediately without fadeout animation. +---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed. +---@overload fun(callback: fun()) +function Menu:close(immediate, callback) + if type(immediate) ~= 'boolean' then callback = immediate end + + local menu = self == Menu and Elements.menu or self + + if menu and not menu.destroyed then + if menu.is_closing then + menu:tween_stop() + return + end + + local function close() + Elements:remove('menu') + menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {} + menu:disable_key_bindings() + Elements:update_proximities() + if callback then callback() end + request_render() + end + + menu.is_closing = true + + if immediate then close() + else menu:fadeout(close) end + end +end + +---@param data MenuData +---@param callback fun(value: any) +---@param opts? MenuOptions +---@return Menu +function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end +---@param data MenuData +---@param callback fun(value: any) +---@param opts? MenuOptions +function Menu:init(data, callback, opts) + Element.init(self, 'menu', {ignores_menu = true}) + + -----@type fun() + self.callback = callback + self.opts = opts or {} + self.offset_x = 0 -- Used for submenu transition animation. + self.item_height = nil + self.item_spacing = 1 + self.item_padding = nil + self.font_size = nil + self.font_size_hint = nil + self.scroll_step = nil -- Item height + item spacing. + self.scroll_height = nil -- Items + spacings - container height. + self.opacity = 0 -- Used to fade in/out. + self.type = data.type + ---@type MenuStack Root MenuStack. + self.root = nil + ---@type MenuStack Current MenuStack. + self.current = nil + ---@type MenuStack[] All menus in a flat array. + self.all = nil + ---@type table Map of submenus by their ids, such as `'Tools > Aspect ratio'`. + self.by_id = {} + self.key_bindings = {} + self.is_being_replaced = false + self.is_closing = false + + self:update(data) + + if self.opts.blurred then + if self.current then self.current.selected_index = nil end + else + for _, menu in ipairs(self.all) do + self:scroll_to_index(menu.selected_index, menu) + end + end + + self:tween_property('opacity', 0, 1) + self:enable_key_bindings() + Elements.curtain:fadein() + if self.opts.on_open then self.opts.on_open() end +end + +function Menu:destroy() + Element.destroy(self) + self:disable_key_bindings() + if not self.is_being_replaced then Elements.curtain:fadeout() end + if self.opts.on_close then self.opts.on_close() end +end + +---@param data MenuData +function Menu:update(data) + self.type = data.type + + local new_root = {is_root = true, title_length = text_length(data.title), hint_length = text_length(data.hint)} + local new_all = {} + local new_by_id = {} + local menus_to_serialize = {{new_root, data}} + local old_current_id = self.current and self.current.id + + table_assign(new_root, data, {'title', 'hint', 'keep_open'}) + + local i = 0 + while i < #menus_to_serialize do + i = i + 1 + local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2] + local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id + if not menu.is_root then + menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i) + end + menu.icon = 'chevron_right' + + -- Update items + local first_active_index = nil + menu.items = {} + + for i, item_data in ipairs(menu_data.items or {}) do + if item_data.active and not first_active_index then first_active_index = i end + + local item = {} + table_assign(item, item_data, { + 'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator', + }) + if item.keep_open == nil then item.keep_open = menu.keep_open end + item.title_length = text_length(item.title) + item.hint_length = text_length(item.hint) + + -- Submenu + if item_data.items then + item.parent_menu = menu + menus_to_serialize[#menus_to_serialize + 1] = {item, item_data} + end + + menu.items[i] = item + end + + if menu.is_root then + menu.selected_index = menu_data.selected_index or first_active_index or (#menu.items > 0 and 1 or nil) + end + + -- Retain old state + local old_menu = self.by_id[menu.is_root and '__root__' or menu.id] + if old_menu then table_assign(menu, old_menu, {'selected_index', 'scroll_y'}) end + + new_all[#new_all + 1] = menu + new_by_id[menu.is_root and '__root__' or menu.id] = menu + end + + self.root, self.all, self.by_id = new_root, new_all, new_by_id + self.current = self.by_id[old_current_id] or self.root + local current_selected_index = self.current.selected_index + + self:update_content_dimensions() + -- `update_content_dimensions()` triggers `select_item_below_cursor()` + -- so we need to remember and re-apply `selected_index`. + self.current.selected_index = current_selected_index + self:reset_navigation() +end + +---@param items MenuDataItem[] +function Menu:update_items(items) + local data = table_shallow_copy(self.root) + data.items = items + self:update(data) +end + +function Menu:update_content_dimensions() + self.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height + self.font_size = round(self.item_height * 0.48 * options.font_scale) + self.font_size_hint = self.font_size - 1 + self.item_padding = round((self.item_height - self.font_size) * 0.6) + self.scroll_step = self.item_height + self.item_spacing + + for _, menu in ipairs(self.all) do + -- Estimate width of a widest item + local max_width = 0 + for _, item in ipairs(menu.items) do + local spacings_in_item = 2 + (item.hint and 1 or 0) + (item.icon and 1 or 0) + local icon_width = item.icon and self.font_size or 0 + item.title_width = text_length_width_estimate(item.title_length, self.font_size) + item.hint_width = text_length_width_estimate(item.hint_length, self.font_size_hint) + local estimated_width = item.title_width + item.hint_width + icon_width + + (self.item_padding * spacings_in_item) + if estimated_width > max_width then max_width = estimated_width end + end + + -- Also check menu title + local menu_title_width = text_length_width_estimate(menu.title_length, self.font_size) + if menu_title_width > max_width then max_width = menu_title_width end + + menu.max_width = max_width + end + + self:update_dimensions() +end + +function Menu:update_dimensions() + -- Coordinates and sizes are of the scrollable area to make + -- consuming values in rendering and collisions easier. Title drawn above this, so + -- we need to account for that in max_height and ay position. + local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width + + for _, menu in ipairs(self.all) do + menu.width = round(clamp(min_width, menu.max_width, display.width * 0.9)) + local title_height = (menu.is_root and menu.title) and self.scroll_step or 0 + local max_height = round((display.height - title_height) * 0.9) + local content_height = self.scroll_step * #menu.items + menu.height = math.min(content_height - self.item_spacing, max_height) + menu.top = round(math.max((display.height - menu.height) / 2, title_height * 1.5)) + menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0) + self:scroll_to(menu.scroll_y, menu) -- re-applies scroll limits + end + + local ax = round((display.width - self.current.width) / 2) + self.offset_x + self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height) +end + +function Menu:reset_navigation() + local menu = self.current + + -- Reset indexes and scroll + self:select_index(menu.selected_index or (menu.items and #menu.items > 0 and 1 or nil)) + self:scroll_to(menu.scroll_y) + + -- Walk up the parent menu chain and activate items that lead to current menu + local parent = menu.parent_menu + while parent do + parent.selected_index = itable_index_of(parent.items, menu) + menu, parent = parent, parent.parent_menu + end + + request_render() +end + +function Menu:set_offset_x(offset) + local delta = offset - self.offset_x + self.offset_x = offset + self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by) +end + +function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end + +function Menu:get_item_index_below_cursor() + local menu = self.current + if #menu.items < 1 or self.proximity_raw > 0 then return nil end + return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items)) +end + +function Menu:get_first_active_index(menu) + menu = menu or self.current + for index, item in ipairs(self.current.items) do + if item.active then return index end + end +end + +---@param pos? number +---@param menu? MenuStack +function Menu:scroll_to(pos, menu) + menu = menu or self.current + menu.scroll_y = clamp(0, pos or 0, menu.scroll_height) + request_render() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:scroll_to_index(index, menu) + menu = menu or self.current + if (index and index >= 1 and index <= #menu.items) then + self:scroll_to(round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2)), menu) + end +end + +---@param index? integer +---@param menu? MenuStack +function Menu:select_index(index, menu) + menu = menu or self.current + menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil + request_render() +end + +---@param value? any +---@param menu? MenuStack +function Menu:select_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(_, item) return item.value == value end) + self:select_index(index, 5) +end + +---@param menu? MenuStack +function Menu:deactivate_items(menu) + menu = menu or self.current + for _, item in ipairs(menu.items) do item.active = false end + request_render() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:activate_index(index, menu) + menu = menu or self.current + if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end + request_render() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:activate_unique_index(index, menu) + self:deactivate_items(menu) + self:activate_index(index, menu) +end + +---@param value? any +---@param menu? MenuStack +function Menu:activate_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(_, item) return item.value == value end) + self:activate_index(index, menu) +end + +---@param value? any +---@param menu? MenuStack +function Menu:activate_unique_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(_, item) return item.value == value end) + self:activate_unique_index(index, menu) +end + +---@param id string +function Menu:activate_submenu(id) + local submenu = self.by_id[id] + if submenu then + self.current = submenu + request_render() + else + msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id)) + end + self:reset_navigation() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:delete_index(index, menu) + menu = menu or self.current + if (index and index >= 1 and index <= #menu.items) then + table.remove(menu.items, index) + self:update_content_dimensions() + self:scroll_to_index(menu.selected_index, menu) + end +end + +---@param value? any +---@param menu? MenuStack +function Menu:delete_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(_, item) return item.value == value end) + self:delete_index(index) +end + +---@param menu? MenuStack +function Menu:prev(menu) + menu = menu or self.current + menu.selected_index = math.max(menu.selected_index and menu.selected_index - 1 or #menu.items, 1) + self:scroll_to_index(menu.selected_index, menu) +end + +---@param menu? MenuStack +function Menu:next(menu) + menu = menu or self.current + menu.selected_index = math.min(menu.selected_index and menu.selected_index + 1 or 1, #menu.items) + self:scroll_to_index(menu.selected_index, menu) +end + +function Menu:back() + local menu = self.current + local parent = menu.parent_menu + + if not parent then return self:close() end + + menu.selected_index = nil + self.current = parent + self:update_dimensions() + self:tween(self.offset_x - menu.width / 2, 0, function(offset) self:set_offset_x(offset) end) + self.opacity = 1 -- in case tween above canceled fade in animation +end + +---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean} +function Menu:open_selected_item(opts) + opts = opts or {} + local menu = self.current + if menu.selected_index then + local item = menu.items[menu.selected_index] + -- Is submenu + if item.items then + self.current = item + if opts.preselect_submenu_item then + item.selected_index = #item.items > 0 and 1 or nil + end + self:update_dimensions() + self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end) + self.opacity = 1 -- in case tween above canceled fade in animation + else + self.callback(item.value) + if not item.keep_open and not opts.keep_open then self:close() end + end + end +end + +function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end +function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end +function Menu:select_item_below_cursor() self.current.selected_index = self:get_item_index_below_cursor() end + +function Menu:on_display() self:update_dimensions() end +function Menu:on_prop_fullormaxed() self:update_content_dimensions() end + +function Menu:on_global_mbtn_left_down() + if self.proximity_raw == 0 then + self:select_item_below_cursor() + self:open_selected_item({preselect_submenu_item = false}) + else + if cursor.x < self.ax then self:back() + else self:close() end + end +end + +function Menu:on_global_mouse_move() + if self.proximity_raw == 0 then self:select_item_below_cursor() + else self.current.selected_index = nil end + request_render() +end + +function Menu:on_wheel_up() + self:scroll_to(self.current.scroll_y - self.scroll_step * 3) + self:on_global_mouse_move() -- selects item below cursor + request_render() +end + +function Menu:on_wheel_down() + self:scroll_to(self.current.scroll_y + self.scroll_step * 3) + self:on_global_mouse_move() -- selects item below cursor + request_render() +end + +function Menu:on_pgup() + local menu = self.current + local items_per_page = round((menu.height / self.scroll_step) * 0.4) + local paged_index = (menu.selected_index and menu.selected_index or #menu.items) - items_per_page + menu.selected_index = clamp(1, paged_index, #menu.items) + if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end +end + +function Menu:on_pgdwn() + local menu = self.current + local items_per_page = round((menu.height / self.scroll_step) * 0.4) + local paged_index = (menu.selected_index and menu.selected_index or 1) + items_per_page + menu.selected_index = clamp(1, paged_index, #menu.items) + if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end +end + +function Menu:on_home() + self.current.selected_index = math.min(1, #self.current.items) + if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end +end + +function Menu:on_end() + self.current.selected_index = #self.current.items + if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end +end + +function Menu:add_key_binding(key, name, fn, flags) + self.key_bindings[#self.key_bindings + 1] = name + mp.add_forced_key_binding(key, name, fn, flags) +end + +function Menu:enable_key_bindings() + -- The `mp.set_key_bindings()` method would be easier here, but that + -- doesn't support 'repeatable' flag, so we are stuck with this monster. + self:add_key_binding('up', 'menu-prev1', self:create_action('prev'), 'repeatable') + self:add_key_binding('down', 'menu-next1', self:create_action('next'), 'repeatable') + self:add_key_binding('left', 'menu-back1', self:create_action('back')) + self:add_key_binding('right', 'menu-select1', self:create_action('open_selected_item_preselect')) + self:add_key_binding('shift+right', 'menu-select-soft1', self:create_action('open_selected_item_soft')) + self:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_action('open_selected_item_soft')) + self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_action('back')) + self:add_key_binding('bs', 'menu-back-alt4', self:create_action('back')) + self:add_key_binding('enter', 'menu-select-alt3', self:create_action('open_selected_item_preselect')) + self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_action('open_selected_item_preselect')) + self:add_key_binding('shift+enter', 'menu-select-alt5', self:create_action('open_selected_item_soft')) + self:add_key_binding('shift+kp_enter', 'menu-select-alt6', self:create_action('open_selected_item_soft')) + self:add_key_binding('esc', 'menu-close', self:create_action('close')) + self:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup')) + self:add_key_binding('pgdwn', 'menu-page-down', self:create_action('on_pgdwn')) + self:add_key_binding('home', 'menu-home', self:create_action('on_home')) + self:add_key_binding('end', 'menu-end', self:create_action('on_end')) +end + +function Menu:disable_key_bindings() + for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end + self.key_bindings = {} +end + +function Menu:create_action(name) + return function(...) self:maybe(name, ...) end +end + +function Menu:render() + local ass = assdraw.ass_new() + local opacity = options.menu_opacity * self.opacity + local spacing = self.item_padding + local icon_size = self.font_size + + function draw_menu(menu, x, y, opacity) + local ax, ay, bx, by = x, y, x + menu.width, y + menu.height + local draw_title = menu.is_root and menu.title + local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')' + local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1 + local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step) + local selected_index = menu.selected_index or -1 + -- remove menu_opacity to start off with full opacity, but still decay for parent menus + local text_opacity = opacity / options.menu_opacity + + -- Background + ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, { + color = options.background, opacity = opacity, radius = 4, + }) + + for index = start_index, end_index, 1 do + local item = menu.items[index] + local next_item = menu.items[index + 1] + local is_highlighted = selected_index == index or item.active + local next_is_active = next_item and next_item.active + local next_is_highlighted = selected_index == index + 1 or next_is_active + + if not item then break end + + local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1) + local item_by = item_ay + self.item_height + local item_center_y = item_ay + (self.item_height / 2) + local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil + -- controls title & hint clipping proportional to the ratio of their widths + local title_hint_ratio = item.hint and item.title_width / (item.title_width + item.hint_width) or 1 + local content_ax, content_bx = ax + spacing, bx - spacing + local font_color = item.active and options.foreground_text or options.background_text + local shadow_color = item.active and options.foreground or options.background + + -- Separator + local separator_ay = item.separator and item_by - 1 or item_by + local separator_by = item_by + (item.separator and 2 or 1) + if is_highlighted then separator_ay = item_by + 1 end + if next_is_highlighted then separator_by = item_by end + if separator_by - separator_ay > 0 and item_by < by then + ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, { + color = options.foreground, opacity = opacity * (item.separator and 0.08 or 0.06), + }) + end + + -- Highlight + local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0) + if highlight_opacity > 0 then + ass:rect(ax + 2, item_ay, bx - 2, item_by, { + radius = 2, color = options.foreground, opacity = highlight_opacity * text_opacity, + clip = item_clip, + }) + end + + -- Icon + if item.icon then + ass:icon(content_bx - (icon_size / 2), item_center_y, icon_size * 1.5, item.icon, { + color = font_color, opacity = text_opacity, clip = item_clip, + shadow = 1, shadow_color = shadow_color, + }) + content_bx = content_bx - icon_size - spacing + end + + local title_hint_cut_x = content_ax + (content_bx - content_ax - spacing) * title_hint_ratio + + -- Hint + if item.hint then + item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint) + local clip = '\\clip(' .. round(title_hint_cut_x + spacing / 2) .. ',' .. + math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')' + ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, { + size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * opacity, clip = clip, + shadow = 1, shadow_color = shadow_color, + }) + end + + -- Title + if item.title then + item.ass_safe_title = item.ass_safe_title or ass_escape(item.title) + local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ',' + .. round(title_hint_cut_x - spacing / 2) .. ',' .. math.min(item_by, by) .. ')' + ass:txt(content_ax, item_center_y, 4, item.ass_safe_title, { + size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2, + opacity = text_opacity * (item.muted and 0.5 or 1), clip = clip, + shadow = 1, shadow_color = shadow_color, + }) + end + end + + -- Menu title + if draw_title then + local title_ay = ay - self.item_height + local title_height = self.item_height - 3 + menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title) + + -- Background + ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, { + color = options.foreground, opacity = opacity * 0.8, radius = 2, + }) + ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', { + size = 80, color = options.background, opacity = opacity * 0.1, + }) + + -- Title + ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.title, { + size = self.font_size, bold = true, color = options.background, wrap = 2, opacity = opacity, + clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')', + }) + end + + -- Scrollbar + if menu.scroll_height > 0 then + local groove_height = menu.height - 2 + local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40) + local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height)) + ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, { + color = options.foreground, opacity = opacity * 0.8, + }) + end + end + + -- Main menu + draw_menu(self.current, self.ax, self.ay, opacity) + + -- Parent menus + local parent_menu = self.current.parent_menu + local parent_offset_x = self.ax + local parent_opacity_factor = options.menu_parent_opacity + local menu_gap = 2 + + while parent_menu do + parent_offset_x = parent_offset_x - parent_menu.width - menu_gap + draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity) + parent_opacity_factor = parent_opacity_factor * parent_opacity_factor + parent_menu = parent_menu.parent_menu + end + + -- Selected menu + local selected_menu = self.current.items[self.current.selected_index] + + if selected_menu and selected_menu.items then + draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity) + end + + return ass +end + +--[[ Speed ]] + +---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; } + +---@class Speed : Element +local Speed = class(Element) + +---@param props? ElementProps +function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end +function Speed:init(props) + Element.init(self, 'speed', props) + + self.width = 0 + self.height = 0 + self.notches = 10 + self.notch_every = 0.1 + ---@type number + self.notch_spacing = nil + ---@type number + self.font_size = nil + ---@type Dragging|nil + self.dragging = nil +end + +function Speed:get_visibility() + -- We force inherit, because I want to see speed value when peeking timeline + local this_visibility = Element.get_visibility(self) + return Elements.timeline.proximity_raw ~= 0 + and math.max(Elements.timeline.proximity, this_visibility) or this_visibility +end + +function Speed:on_coordinates() + self.height, self.width = self.by - self.ay, self.bx - self.ax + self.notch_spacing = self.width / (self.notches + 1) + self.font_size = round(self.height * 0.48 * options.font_scale) +end + +function Speed:speed_step(speed, up) + if options.speed_step_is_factor then + if up then + return speed * options.speed_step + else + return speed * 1 / options.speed_step + end + else + if up then + return speed + options.speed_step + else + return speed - options.speed_step + end + end +end + +function Speed:on_mbtn_left_down() + self:tween_stop() -- Stop and cleanup possible ongoing animations + self.dragging = { + start_time = mp.get_time(), + start_x = cursor.x, + distance = 0, + speed_distance = 0, + start_speed = state.speed, + } +end + +function Speed:on_global_mouse_move() + if not self.dragging then return end + + self.dragging.distance = cursor.x - self.dragging.start_x + self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every) + + local speed_current = state.speed + local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance + speed_drag_current = clamp(0.01, speed_drag_current, 100) + local drag_dir_up = speed_drag_current > speed_current + + local speed_step_next = speed_current + local speed_drag_diff = math.abs(speed_drag_current - speed_current) + while math.abs(speed_step_next - speed_current) < speed_drag_diff do + speed_step_next = self:speed_step(speed_step_next, drag_dir_up) + end + local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up) + + local speed_new = speed_step_prev + local speed_next_diff = math.abs(speed_drag_current - speed_step_next) + local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev) + if speed_next_diff < speed_prev_diff then + speed_new = speed_step_next + end + + if speed_new ~= speed_current then + mp.set_property_native('speed', speed_new) + end +end + +function Speed:on_mbtn_left_up() + -- Reset speed on short clicks + if self.dragging and math.abs(self.dragging.distance) < 6 and mp.get_time() - self.dragging.start_time < 0.15 then + mp.set_property_native('speed', 1) + end +end + +function Speed:on_global_mbtn_left_up() + self.dragging = nil + request_render() +end + +function Speed:on_global_mouse_leave() + self.dragging = nil + request_render() +end + +function Speed:on_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end +function Speed:on_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end + +function Speed:render() + if not self.dragging and (Elements.curtain.opacity > 0) then return end + + local visibility = self:get_visibility() + local opacity = self.dragging and 1 or visibility + + if opacity <= 0 then return end + + local ass = assdraw.ass_new() + + -- Background + ass:rect(self.ax, self.ay, self.bx, self.by, { + color = options.background, radius = 2, opacity = opacity * options.speed_opacity, + }) + + -- Coordinates + local ax, ay = self.ax, self.ay + local bx, by = self.bx, ay + self.height + local half_width = (self.width / 2) + local half_x = ax + half_width + + -- Notches + local speed_at_center = state.speed + if self.dragging then + speed_at_center = self.dragging.start_speed + self.dragging.speed_distance + speed_at_center = clamp(0.01, speed_at_center, 100) + end + local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every + local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing) + local guide_size = math.floor(self.height / 7.5) + local notch_by = by - guide_size + local notch_ay_big = ay + round(self.font_size * 1.1) + local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2) + local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4) + local from_to_index = math.floor(self.notches / 2) + + for i = -from_to_index, from_to_index do + local notch_speed = nearest_notch_speed + (i * self.notch_every) + + if notch_speed >= 0 and notch_speed <= 100 then + local notch_x = nearest_notch_x + (i * self.notch_spacing) + local notch_thickness = 1 + local notch_ay = notch_ay_small + if (notch_speed % (self.notch_every * 10)) < 0.00000001 then + notch_ay = notch_ay_big + notch_thickness = 1.5 + elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then + notch_ay = notch_ay_medium + end + + ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, { + color = options.foreground, border = 1, border_color = options.background, + opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity, + }) + end + end + + -- Center guide + ass:new_event() + ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. options.foreground .. '\\3c&H' .. options.background .. '}') + ass:opacity(opacity) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(half_x, by - 2 - guide_size) + ass:line_to(half_x + guide_size, by - 2) + ass:line_to(half_x - guide_size, by - 2) + ass:draw_stop() + + -- Speed value + local speed_text = (round(state.speed * 100) / 100) .. 'x' + ass:txt(half_x, ay, 8, speed_text, { + size = self.font_size, color = options.background_text, + border = options.text_border, border_color = options.background, opacity = opacity, + }) + + return ass +end + +--[[ Button ]] + +---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string} + +---@class Button : Element +local Button = class(Element) + +---@param id string +---@param props ButtonProps +function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end +---@param id string +---@param props ButtonProps +function Button:init(id, props) + self.icon = props.icon + self.active = props.active + self.tooltip = props.tooltip + self.badge = props.badge + self.foreground = props.foreground or options.foreground + self.background = props.background or options.background + ---@type fun() + self.on_click = props.on_click + Element.init(self, id, props) +end + +function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end +function Button:on_mbtn_left_down() + -- We delay the callback to next tick, otherwise we are risking race + -- conditions as we are in the middle of event dispatching. + -- For example, handler might add a menu to the end of the element stack, and that + -- than picks up this click even we are in right now, and instantly closes itself. + mp.add_timeout(0.01, self.on_click) +end + +function Button:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + + local ass = assdraw.ass_new() + local is_hover = self.proximity_raw == 0 + local is_hover_or_active = is_hover or self.active + local foreground = self.active and self.background or self.foreground + local background = self.active and self.foreground or self.background + + -- Background + if is_hover_or_active then + ass:rect(self.ax, self.ay, self.bx, self.by, { + color = self.active and background or foreground, radius = 2, + opacity = visibility * (self.active and 0.8 or 0.3), + }) + end + + -- Tooltip on hover + if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end + + -- Badge + local icon_clip + if self.badge then + local badge_font_size = self.font_size * 0.6 + local badge_width = text_width_estimate(self.badge, badge_font_size) + local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93) + local bx, by = self.bx - 1, self.by - 1 + ass:rect(bx - width, by - height, bx, by, { + color = foreground, radius = 2, opacity = visibility, + border = self.active and 0 or 1, border_color = background, + }) + ass:txt(bx - width / 2, by - height / 2, 5, self.badge, { + size = badge_font_size, color = background, opacity = visibility, + }) + + local clip_border = math.max(self.font_size / 20, 1) + local clip_path = assdraw.ass_new() + clip_path:round_rect_cw( + math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3 + ) + icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')' + end + + -- Icon + local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2) + ass:icon(x, y, self.font_size, self.icon, { + color = foreground, border = self.active and 0 or options.text_border, border_color = background, + opacity = visibility, clip = icon_clip, + }) + + return ass +end + +--[[ CycleButton ]] + +---@alias CycleState {value: any; icon: string; active?: boolean} +---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string} + +---@class CycleButton : Button +local CycleButton = class(Button) + +---@param id string +---@param props CycleButtonProps +function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end +---@param id string +---@param props CycleButtonProps +function CycleButton:init(id, props) + local is_state_prop = itable_index_of({'shuffle'}, props.prop) + self.prop = props.prop + self.states = props.states + + Button.init(self, id, props) + + self.icon = self.states[1].icon + self.active = self.states[1].active + self.current_state_index = 1 + self.on_click = function() + local new_state = self.states[self.current_state_index + 1] or self.states[1] + if is_state_prop then + local new_value = new_state.value + if itable_index_of({'yes', 'no'}, new_state.value) then new_value = new_value == 'yes' end + set_state(self.prop, new_value) + else + mp.set_property(self.prop, new_state.value) + end + end + + self.handle_change = function(name, value) + if is_state_prop and type(value) == 'boolean' then value = value and 'yes' or 'no' end + local index = itable_find(self.states, function(state) return state.value == value end) + self.current_state_index = index or 1 + self.icon = self.states[self.current_state_index].icon + self.active = self.states[self.current_state_index].active + request_render() + end + + -- Built in state props + if is_state_prop then + self['on_prop_' .. self.prop] = function(self, value) self.handle_change(self.prop, value) end + else + mp.observe_property(self.prop, 'string', self.handle_change) + end +end + +function CycleButton:destroy() + Button.destroy(self) + mp.unobserve_property(self.handle_change) +end + +--[[ WindowBorder ]] + +---@class WindowBorder : Element +local WindowBorder = class(Element) + +function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end +function WindowBorder:init() + Element.init(self, 'window_border') + self.ignores_menu = true + self.size = 0 +end + +function WindowBorder:decide_enabled() + self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border + self.size = self.enabled and options.window_border_size or 0 +end + +function WindowBorder:on_prop_border() self:decide_enabled() end +function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end + +function WindowBorder:render() + if self.size > 0 then + local ass = assdraw.ass_new() + local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' .. + (display.width - self.size) .. ',' .. (display.height - self.size) .. ')' + ass:rect(0, 0, display.width + 1, display.height + 1, { + color = options.background, clip = clip, opacity = options.window_border_opacity, + }) + return ass + end +end + +--[[ PauseIndicator ]] + +---@class PauseIndicator : Element +local PauseIndicator = class(Element) + +function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end +function PauseIndicator:init() + Element.init(self, 'pause_indicator') + self.ignores_menu = true + self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8 + self.paused = state.pause + self.type = options.pause_indicator + self.is_manual = options.pause_indicator == 'manual' + self.fadeout_requested = false + self.opacity = 0 + + mp.observe_property('pause', 'bool', function(_, paused) + if options.pause_indicator == 'flash' then + if self.paused == paused then return end + self:flash() + elseif options.pause_indicator == 'static' then + self:decide() + end + end) +end + +function PauseIndicator:flash() + if not self.is_manual and self.type ~= 'flash' then return end + -- can't wait for pause property event listener to set this, because when this is used inside a binding like: + -- cycle pause; script-binding uosc/flash-pause-indicator + -- the pause event is not fired fast enough, and indicator starts rendering with old icon + self.paused = mp.get_property_native('pause') + if self.is_manual then self.type = 'flash' end + self.opacity = 1 + self:tween_property('opacity', 1, 0, 0.15) +end + +-- decides whether static indicator should be visible or not +function PauseIndicator:decide() + if not self.is_manual and self.type ~= 'static' then return end + self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary + if self.is_manual then self.type = 'static' end + self.opacity = self.paused and 1 or 0 + request_render() + + -- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored. + -- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more + mp.add_timeout(.05, function() osd:update() end) +end + +function PauseIndicator:render() + if self.opacity == 0 then return end + + local ass = assdraw.ass_new() + local is_static = self.type == 'static' + + -- Background fadeout + if is_static then + ass:rect(0, 0, display.width, display.height, {color = options.background, opacity = self.opacity * 0.3}) + end + + -- Icon + local size = round(math.min(display.width, display.height) * (is_static and 0.20 or 0.15)) + size = size + size * (1 - self.opacity) + + if self.paused then + ass:icon(display.width / 2, display.height / 2, size, 'pause', + {border = 1, opacity = self.base_icon_opacity * self.opacity} + ) + else + ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow', + {border = 1, opacity = self.base_icon_opacity * self.opacity} + ) + end + + return ass +end + +--[[ Timeline ]] + +---@class Timeline : Element +local Timeline = class(Element) + +function Timeline:new() return Class.new(self) --[[@as Timeline]] end +function Timeline:init() + Element.init(self, 'timeline') + self.pressed = false + self.size_max = 0 + self.size_min = 0 + self.size_min_override = options.timeline_start_hidden and 0 or nil + self.font_size = 0 + self.top_border = options.timeline_border + + -- Release any dragging when file gets unloaded + mp.register_event('end-file', function() self.pressed = false end) +end + +function Timeline:get_visibility() + return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self)) + or Element.get_visibility(self) +end + +function Timeline:decide_enabled() + self.enabled = state.duration and state.duration > 0 and state.time +end + +function Timeline:get_effective_size_min() + return self.size_min_override or self.size_min +end + +function Timeline:get_effective_size() + if Elements.speed and Elements.speed.dragging then return self.size_max end + local size_min = self:get_effective_size_min() + return size_min + math.ceil((self.size_max - size_min) * self:get_visibility()) +end + +function Timeline:get_effective_line_width() + return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width +end + +function Timeline:update_dimensions() + if state.fullormaxed then + self.size_min = options.timeline_size_min_fullscreen + self.size_max = options.timeline_size_max_fullscreen + else + self.size_min = options.timeline_size_min + self.size_max = options.timeline_size_max + end + self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale) + self.ax = Elements.window_border.size + self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border + self.bx = display.width - Elements.window_border.size + self.by = display.height - Elements.window_border.size + self.width = self.bx - self.ax +end + +function Timeline:get_time_at_x(x) + -- line width 1 for timeline_style=bar so mouse input can go all the way from 0 to 1 progress + local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() or 1) + local time_width = self.width - line_width + local progress_x = x - self.ax - line_width / 2 + local progress = clamp(0, progress_x / time_width, 1) + return state.duration * progress +end + +function Timeline:set_from_cursor() + mp.commandv('seek', self:get_time_at_x(cursor.x), 'absolute+exact') +end + +function Timeline:on_mbtn_left_down() + self.pressed = true + self:set_from_cursor() +end + +function Timeline:on_prop_duration() self:decide_enabled() end +function Timeline:on_prop_time() self:decide_enabled() end +function Timeline:on_prop_border() self:update_dimensions() end +function Timeline:on_prop_fullormaxed() self:update_dimensions() end +function Timeline:on_display() self:update_dimensions() end +function Timeline:on_mouse_leave() mp.commandv('script-message-to', 'thumbfast', 'clear') end +function Timeline:on_global_mbtn_left_up() self.pressed = false end +function Timeline:on_global_mouse_leave() self.pressed = false end +function Timeline:on_global_mouse_move() + if self.pressed then self:set_from_cursor() end +end +function Timeline:on_wheel_up() mp.commandv('seek', options.timeline_step) end +function Timeline:on_wheel_down() mp.commandv('seek', -options.timeline_step) end + +function Timeline:render() + if self.size_max == 0 then return end + + local size_min = self:get_effective_size_min() + local size = self:get_effective_size() + local visibility = self:get_visibility() + + if size < 1 then return end + + local ass = assdraw.ass_new() + + -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min + local hide_text_below = math.max(self.font_size * 0.8, size_min * 2) + local hide_text_ramp = hide_text_below / 2 + local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp + + local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4) + local progress = state.time / state.duration + local is_line = options.timeline_style == 'line' + + -- Foreground & Background bar coordinates + local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by + local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby + local fcy = fay + (size / 2) + + local time_x = bax + self.width * progress + local line_width, past_x_adjustment, future_x_adjustment = 0, 1, 1 + + if is_line then + local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1) + local width_normal = self:get_effective_line_width() + local max_min_width_delta = size_min > 0 + and width_normal - width_normal * options.timeline_line_width_minimized_scale + or 0 + line_width = width_normal - (max_min_width_delta * minimized_fraction) + fax = bax + (self.width - line_width) * progress + fbx = fax + line_width + local past_time_width, future_time_width = time_x - bax, bbx - time_x + past_x_adjustment = (past_time_width - (time_x - fax)) / past_time_width + future_x_adjustment = (future_time_width - (fbx - time_x)) / future_time_width + else + fax, fbx = bax, bax + self.width * progress + end + + local foreground_size = fby - fay + local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping + + -- line_x_adjustment: adjusts x coordinate so that it never lies inside of the line + -- it's as if line cuts the timeline and squeezes itself into the cut + local lxa = line_width == 0 and function(x) return x end or function(x) + return x < time_x and bax + (x - bax) * past_x_adjustment or bbx - (bbx - x) * future_x_adjustment + end + + -- Background + ass:new_event() + ass:pos(0, 0) + ass:append('{\\blur0\\bord0\\1c&H' .. options.background .. '}') + ass:opacity(math.max(options.timeline_opacity - 0.1, 0)) + ass:draw_start() + ass:rect_cw(bax, bay, fax, bby) --left of progress + ass:rect_cw(fbx, bay, bbx, bby) --right of progress + ass:rect_cw(fax, bay, fbx, fay) --above progress + ass:draw_stop() + + -- Progress + ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity}) + + -- Uncached ranges + local buffered_time = nil + if state.uncached_ranges then + local opts = {size = 80, anchor_y = fby} + local texture_char = visibility > 0 and 'b' or 'a' + local offset = opts.size / (visibility > 0 and 24 or 28) + for _, range in ipairs(state.uncached_ranges) do + if not buffered_time and (range[1] > state.time or range[2] > state.time) then + buffered_time = range[1] - state.time + end + local ax = range[1] < 0.5 and bax or math.floor(lxa(bax + self.width * (range[1] / state.duration))) + local bx = range[2] > state.duration - 0.5 and bbx or + math.ceil(lxa(bax + self.width * (range[2] / state.duration))) + opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax + ass:texture(ax, fay, bx, fby, texture_char, opts) + opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset + ass:texture(ax, fay, bx, fby, texture_char, opts) + end + end + + -- Custom ranges + for _, chapter_range in ipairs(state.chapter_ranges) do + local rax = chapter_range.start < 0.1 and 0 or lxa(bax + self.width * (chapter_range.start / state.duration)) + local rbx = chapter_range['end'] > state.duration - 0.1 and bbx + or lxa(bax + self.width * math.min(chapter_range['end'] / state.duration, 1)) + ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity}) + end + + -- Chapters + if (options.timeline_chapters_opacity > 0 + and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b) + ) then + local diamond_radius = foreground_size < 3 and foreground_size or math.max(foreground_size / 10, 3) + local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1 + + if diamond_radius > 0 then + local function draw_chapter(time) + local chapter_x = bax + line_width / 2 + (self.width - line_width) * (time / state.duration) + local chapter_y = fay - 1 + ass:new_event() + ass:append(string.format( + '{\\pos(0,0)\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}', + diamond_border, options.foreground, options.background, options.background, + opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity) + )) + ass:draw_start() + ass:move_to(chapter_x - diamond_radius, chapter_y) + ass:line_to(chapter_x, chapter_y - diamond_radius) + ass:line_to(chapter_x + diamond_radius, chapter_y) + ass:line_to(chapter_x, chapter_y + diamond_radius) + ass:draw_stop() + end + + if state.chapters ~= nil then + for i, chapter in ipairs(state.chapters) do + if not chapter.is_range_point then draw_chapter(chapter.time) end + end + end + + if state.ab_loop_a and state.ab_loop_a > 0 then draw_chapter(state.ab_loop_a) end + if state.ab_loop_b and state.ab_loop_b > 0 then draw_chapter(state.ab_loop_b) end + end + end + + local function draw_timeline_text(x, y, align, text, opts) + opts.color, opts.border_color = options.foreground_text, options.foreground + opts.clip = '\\clip(' .. foreground_coordinates .. ')' + ass:txt(x, y, align, text, opts) + opts.color, opts.border_color = options.background_text, options.background + opts.clip = '\\iclip(' .. foreground_coordinates .. ')' + ass:txt(x, y, align, text, opts) + end + + -- Time values + if text_opacity > 0 then + -- Upcoming cache time + if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then + local x, align = fbx + 5, 4 + local font_size = self.font_size * 0.8 + local human = round(math.max(buffered_time, 0)) .. 's' + local width = text_width_estimate(human, font_size) + local min_x = bax + 5 + text_width_estimate(state.time_human, self.font_size) + local max_x = bbx - 5 - text_width_estimate(state.duration_or_remaining_time_human, self.font_size) + if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end + draw_timeline_text(x, fcy, align, human, {size = font_size, opacity = text_opacity * 0.6, border = 1}) + end + + local opts = {size = self.font_size, opacity = text_opacity, border = 2} + + -- Elapsed time + if state.time_human then + draw_timeline_text(bax + spacing, fcy, 4, state.time_human, opts) + end + + -- End time + if state.duration_or_remaining_time_human then + draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, opts) + end + end + + -- Hovered time and chapter + if (self.proximity_raw == 0 or self.pressed) and not (Elements.speed and Elements.speed.dragging) then + local hovered_seconds = self:get_time_at_x(cursor.x) + + -- Cursor line + -- 0.5 to switch when the pixel is half filled in + local color = ((fax - 0.5) < cursor.x and cursor.x < (fbx + 0.5)) and + options.background or options.foreground + local ax, ay, bx, by = cursor.x - 0.5, fay, cursor.x + 0.5, fby + ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2}) + local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by} + + -- Timestamp + ass:tooltip(tooltip_anchor, format_time(hovered_seconds), {size = self.font_size, offset = 4}) + tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - 4 + + -- Thumbnail + if not thumbnail.disabled and thumbnail.width ~= 0 and thumbnail.height ~= 0 then + local scale_x, scale_y = display.scale_x, display.scale_y + local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y) + local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y + local thumb_width, thumb_height = thumbnail.width, thumbnail.height + local thumb_x = round(clamp( + thumb_x_margin, cursor.x * scale_x - thumb_width / 2, + display.width * scale_x - thumb_width - thumb_x_margin + )) + local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height) + local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y + local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y + ass:rect(ax, ay, bx, by, { + color = options.foreground, border = 1, border_color = options.background, radius = 3, opacity = 0.8, + }) + mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y) + tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay + end + + -- Chapter title + if #state.chapters > 0 then + local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true) + if chapter and not chapter.is_end_only then + ass:tooltip(tooltip_anchor, chapter.title_wrapped, { + size = self.font_size, offset = 10, responsive = false, bold = true, + text_length_override = chapter.title_wrapped_width, + }) + end + end + end + + return ass +end + +--[[ TopBarButton ]] + +---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()} + +---@class TopBarButton : Element +local TopBarButton = class(Element) + +---@param id string +---@param props TopBarButtonProps +function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end +function TopBarButton:init(id, props) + Element.init(self, id, props) + self.anchor_id = 'top_bar' + self.icon = props.icon + self.background = props.background + self.command = props.command +end + +function TopBarButton:on_mbtn_left_down() + mp.command(type(self.command) == 'function' and self.command() or self.command) +end + +function TopBarButton:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + local ass = assdraw.ass_new() + + -- Background on hover + if self.proximity_raw == 0 then + ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility}) + end + + local width, height = self.bx - self.ax, self.by - self.ay + local icon_size = math.min(width, height) * 0.5 + ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, { + opacity = visibility, border = options.text_border, + }) + + return ass +end + +--[[ TopBar ]] + +---@class TopBar : Element +local TopBar = class(Element) + +function TopBar:new() return Class.new(self) --[[@as TopBar]] end +function TopBar:init() + Element.init(self, 'top_bar') + self.pressed = false + self.size, self.size_max, self.size_min = 0, 0, 0 + self.icon_size, self.spacing, self.font_size, self.title_bx = 1, 1, 1, 1 + self.size_min_override = options.timeline_start_hidden and 0 or nil + self.top_border = options.timeline_border + + local function decide_maximized_command() + return state.border + and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized') + or 'set window-maximized no;cycle fullscreen' + end + + -- Order aligns from right to left + self.buttons = { + TopBarButton:new('tb_close', {icon = 'close', background = '2311e8', command = 'quit'}), + TopBarButton:new('tb_max', {icon = 'crop_square', background = '222222', command = decide_maximized_command}), + TopBarButton:new('tb_min', {icon = 'minimize', background = '222222', command = 'cycle window-minimized'}), + } +end + +function TopBar:decide_enabled() + if options.top_bar == 'no-border' then + self.enabled = not state.border or state.fullscreen + else + self.enabled = options.top_bar == 'always' + end + self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title) + for _, element in ipairs(self.buttons) do + element.enabled = self.enabled and options.top_bar_controls + end +end + +function TopBar:update_dimensions() + self.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size + self.icon_size = round(self.size * 0.5) + self.spacing = math.ceil(self.size * 0.25) + self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale) + self.button_width = round(self.size * 1.15) + self.ay = Elements.window_border.size + self.bx = display.width - Elements.window_border.size + self.by = self.size + Elements.window_border.size + self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0) + self.ax = options.top_bar_title and Elements.window_border.size or self.title_bx + + local button_bx = self.bx + for _, element in pairs(self.buttons) do + element.ax, element.bx = button_bx - self.button_width, button_bx + element.ay, element.by = self.ay, self.by + button_bx = button_bx - self.button_width + end +end + +function TopBar:on_prop_border() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_prop_fullscreen() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_prop_maximized() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_display() self:update_dimensions() end + +function TopBar:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + local ass = assdraw.ass_new() + + -- Window title + if options.top_bar_title and (state.title or state.has_playlist) then + local bg_margin = math.floor((self.size - self.font_size) / 4) + local padding = self.font_size / 2 + local title_ax = self.ax + bg_margin + local title_ay = self.ay + bg_margin + local max_bx = self.title_bx - self.spacing + + -- Playlist position + if state.has_playlist then + local text = state.playlist_pos .. '' .. state.playlist_count + local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/' + .. state.playlist_count + local bx = round(title_ax + text_length_width_estimate(#text, self.font_size) + padding * 2) + ass:rect(title_ax, title_ay, bx, self.by - bg_margin, { + color = options.foreground, opacity = visibility, radius = 2, + }) + ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, { + size = self.font_size, wrap = 2, color = options.background, opacity = visibility, + }) + title_ax = bx + bg_margin + end + + -- Title + if max_bx - title_ax > self.font_size * 3 then + local text = state.title or 'n/a' + local bx = math.min(max_bx, title_ax + text_width_estimate(text, self.font_size) + padding * 2) + local by = self.by - bg_margin + ass:rect(title_ax, title_ay, bx, by, { + color = options.background, opacity = visibility * options.top_bar_title_opacity, radius = 2, + }) + ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, { + size = self.font_size, wrap = 2, color = options.foreground, border = 1, border_color = options.background, + opacity = visibility, clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by), + }) + title_ay = by + 1 + end + + -- Subtitle: current chapter + if state.current_chapter and max_bx - title_ax > self.font_size * 3 then + local font_size = self.font_size * 0.8 + local height = font_size * 1.5 + local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title + local ax, by = title_ax + padding / 2, title_ay + height + local bx = math.min(max_bx, title_ax + text_width_estimate(text, font_size) + padding * 2) + ass:rect(ax, title_ay, bx, by, { + color = options.background, opacity = visibility * options.top_bar_title_opacity, radius = 2, + }) + ass:txt(ax + padding, title_ay + height / 2, 4, '{\\i1}' .. text .. '{\\i0}', { + size = font_size, wrap = 2, color = options.foreground, border = 1, border_color = options.background, + opacity = visibility * 0.8, clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by), + }) + end + end + + return ass +end + +--[[ Controls ]] + +-- `scale` - `options.controls_size` scale factor. +-- `ratio` - Width/height ratio of a static or dynamic element. +-- `ratio_min` Min ratio for 'dynamic' sized element. +-- `skip` - Whether it should be skipped, determined during layout phase. +---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table} + +---@class Controls : Element +local Controls = class(Element) + +function Controls:new() return Class.new(self) --[[@as Controls]] end +function Controls:init() + Element.init(self, 'controls') + ---@type ControlItem[] All control elements serialized from `options.controls`. + self.controls = {} + ---@type ControlItem[] Only controls that match current dispositions. + self.layout = {} + + -- Serialize control elements + local shorthands = { + menu = 'command:menu:script-binding uosc/menu-blurred?Menu', + subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?Subtitles', + audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?Audio', + ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device', + video = 'command:theaters:script-binding uosc/video#video>1?Video', + playlist = 'command:list_alt:script-binding uosc/playlist?Playlist', + chapters = 'command:bookmarks:script-binding uosc/chapters#chapters>0?Chapters', + ['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?Stream quality', + ['open-file'] = 'command:file_open:script-binding uosc/open-file?Open file', + ['items'] = 'command:list_alt:script-binding uosc/items?Playlist/Files', + prev = 'command:arrow_back_ios:script-binding uosc/prev?Previous', + next = 'command:arrow_forward_ios:script-binding uosc/next?Next', + first = 'command:first_page:script-binding uosc/first?First', + last = 'command:last_page:script-binding uosc/last?Last', + ['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?Loop playlist', + ['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?Loop file', + shuffle = 'toggle:shuffle:shuffle?Shuffle', + fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen', + } + + -- Parse out disposition/config pairs + local items = {} + local in_disposition = false + local current_item = nil + for c in options.controls:gmatch('.') do + if not current_item then current_item = {disposition = '', config = ''} end + if c == '<' and #current_item.config == 0 then in_disposition = true + elseif c == '>' and #current_item.config == 0 then in_disposition = false + elseif c == ',' and not in_disposition then + items[#items + 1] = current_item + current_item = nil + else + local prop = in_disposition and 'disposition' or 'config' + current_item[prop] = current_item[prop] .. c + end + end + items[#items + 1] = current_item + + -- Create controls + self.controls = {} + for i, item in ipairs(items) do + local config = shorthands[item.config] and shorthands[item.config] or item.config + local config_tooltip = split(config, ' *%? *') + local tooltip = config_tooltip[2] + config = shorthands[config_tooltip[1]] + and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1] + local config_badge = split(config, ' *# *') + config = config_badge[1] + local badge = config_badge[2] + local parts = split(config, ' *: *') + local kind, params = parts[1], itable_slice(parts, 2) + + -- Serialize dispositions + local dispositions = {} + for _, definition in ipairs(split(item.disposition, ' *, *')) do + if #definition > 0 then + local value = definition:sub(1, 1) ~= '!' + local name = not value and definition:sub(2) or definition + local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name + dispositions[prop] = value + end + end + + -- Convert toggles into cycles + if kind == 'toggle' then + kind = 'cycle' + params[#params + 1] = 'no/yes!' + end + + -- Create a control element + local control = {dispositions = dispositions, kind = kind} + + if kind == 'space' then + control.sizing = 'space' + elseif kind == 'gap' then + table_assign(control, {sizing = 'dynamic', scale = 1, ratio = params[1] or 0.3, ratio_min = 0}) + elseif kind == 'command' then + if #params ~= 2 then + mp.error(string.format( + 'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/') + )) + else + local element = Button:new('control_' .. i, { + icon = params[1], + anchor_id = 'controls', + on_click = function() mp.command(params[2]) end, + tooltip = tooltip, + count_prop = 'sub', + }) + table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) + if badge then self:register_badge_updater(badge, element) end + end + elseif kind == 'cycle' then + if #params ~= 3 then + mp.error(string.format( + 'cycle button needs 3 parameters, %d received: %s', + #params, table.concat(params, '/') + )) + else + local state_configs = split(params[3], ' */ *') + local states = {} + + for _, state_config in ipairs(state_configs) do + local active = false + if state_config:sub(-1) == '!' then + active = true + state_config = state_config:sub(1, -2) + end + local state_params = split(state_config, ' *= *') + local value, icon = state_params[1], state_params[2] or params[1] + states[#states + 1] = {value = value, icon = icon, active = active} + end + + local element = CycleButton:new('control_' .. i, { + prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip, + }) + table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) + if badge then self:register_badge_updater(badge, element) end + end + elseif kind == 'speed' then + if not Elements.speed then + local element = Speed:new({anchor_id = 'controls'}) + table_assign(control, { + element = element, sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2, + }) + else + msg.error('there can only be 1 speed slider') + end + else + msg.error('unknown element kind "' .. kind .. '"') + break + end + + self.controls[#self.controls + 1] = control + end + + self:reflow() +end + +function Controls:reflow() + -- Populate the layout only with items that match current disposition + self.layout = {} + for _, control in ipairs(self.controls) do + local matches = true + for prop, value in pairs(control.dispositions) do + if state[prop] ~= value then + matches = false + break + end + end + if control.element then control.element.enabled = matches end + if matches then self.layout[#self.layout + 1] = control end + end + + self:update_dimensions() + Elements:trigger('controls_reflow') +end + +---@param badge string +---@param element Element An element that supports `badge` property. +function Controls:register_badge_updater(badge, element) + local prop_and_limit = split(badge, ' *> *') + local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1) + local observable_name, serializer = prop, nil + + if itable_index_of({'sub', 'audio', 'video'}, prop) then + observable_name = 'track-list' + serializer = function(value) + local count = 0 + for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end + return count + end + else + serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end + end + + local function handler(_, value) + local new_value = serializer(value) --[[@as nil|string|integer]] + local value_number = tonumber(new_value) + if value_number then new_value = value_number > limit and value_number or nil end + element.badge = new_value + request_render() + end + + mp.observe_property(observable_name, 'native', handler) +end + +function Controls:get_visibility() + local timeline_is_hovered = Elements.timeline.enabled and Elements.timeline.proximity_raw == 0 + return (Elements.speed and Elements.speed.dragging) and 1 or timeline_is_hovered + and -1 or Element.get_visibility(self) +end + +function Controls:update_dimensions() + local window_border = Elements.window_border.size + local size = state.fullormaxed and options.controls_size_fullscreen or options.controls_size + local spacing = options.controls_spacing + local margin = options.controls_margin + + -- Container + self.bx = display.width - window_border - margin + self.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin + self.ax, self.ay = window_border + margin, self.by - size + + -- Re-enable all elements + for c, control in ipairs(self.layout) do + control.hide = false + if control.element then control.element.enabled = true end + end + + -- Controls + local available_width = self.bx - self.ax + local statics_width = (#self.layout - 1) * spacing + local min_content_width = statics_width + local max_dynamics_width, dynamic_units, spaces = 0, 0, 0 + + -- Calculate statics_width, min_content_width, and count spaces + for c, control in ipairs(self.layout) do + if control.sizing == 'space' then + spaces = spaces + 1 + elseif control.sizing == 'static' then + local width = size * control.scale * control.ratio + statics_width = statics_width + width + min_content_width = min_content_width + width + elseif control.sizing == 'dynamic' then + min_content_width = min_content_width + size * control.scale * control.ratio_min + max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio + dynamic_units = dynamic_units + control.scale * control.ratio + end + end + + -- Hide & disable elements in the middle until we fit into available width + if min_content_width > available_width then + local i = math.ceil(#self.layout / 2 + 0.1) + for a = 0, #self.layout - 1, 1 do + i = i + (a * (a % 2 == 0 and 1 or -1)) + local control = self.layout[i] + + if control.kind ~= 'gap' and control.kind ~= 'space' then + control.hide = true + if control.element then control.element.enabled = false end + if control.sizing == 'static' then + local width = size * control.scale * control.ratio + min_content_width = min_content_width - width - spacing + statics_width = statics_width - width - spacing + elseif control.sizing == 'dynamic' then + min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing + max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio + dynamic_units = dynamic_units - control.scale * control.ratio + end + + if min_content_width < available_width then break end + end + end + end + + -- Lay out the elements + local current_x = self.ax + local width_for_dynamics = available_width - statics_width + local space_width = (width_for_dynamics - max_dynamics_width) / spaces + + for c, control in ipairs(self.layout) do + if not control.hide then + local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio + local width, height = 0, 0 + + if sizing == 'space' then + if space_width > 0 then width = space_width end + elseif sizing == 'static' then + height = size * scale + width = height * ratio + elseif sizing == 'dynamic' then + height = size * scale + width = max_dynamics_width < width_for_dynamics + and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units) + end + + local bx = current_x + width + if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end + current_x = bx + spacing + end + end + + Elements:update_proximities() + request_render() +end + +function Controls:on_dispositions() self:reflow() end +function Controls:on_display() self:update_dimensions() end +function Controls:on_prop_border() self:update_dimensions() end +function Controls:on_prop_fullormaxed() self:update_dimensions() end + +--[[ MuteButton ]] + +---@class MuteButton : Element +local MuteButton = class(Element) +---@param props? ElementProps +function MuteButton:new(props) return Class.new(self, 'volume_mute', props) --[[@as MuteButton]] end +function MuteButton:on_mbtn_left_down() mp.commandv('cycle', 'mute') end +function MuteButton:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + local ass = assdraw.ass_new() + local icon_name = state.mute and 'volume_off' or 'volume_up' + local width = self.bx - self.ax + ass:icon(self.ax + (width / 2), self.by, width * 0.7, icon_name, + {border = options.text_border, opacity = options.volume_opacity * visibility, align = 2} + ) + return ass +end + +--[[ VolumeSlider ]] + +---@class VolumeSlider : Element +local VolumeSlider = class(Element) +---@param props? ElementProps +function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end +function VolumeSlider:init(props) + Element.init(self, 'volume_slider', props) + self.pressed = false + self.nudge_y = 0 -- vertical position where volume overflows 100 + self.nudge_size = 0 + self.draw_nudge = false + self.spacing = 0 + self.radius = 1 +end + +function VolumeSlider:set_volume(volume) + volume = round(volume / options.volume_step) * options.volume_step + if state.volume == volume then return end + mp.commandv('set', 'volume', clamp(0, volume, state.volume_max)) +end + +function VolumeSlider:set_from_cursor() + local volume_fraction = (self.by - cursor.y - options.volume_border) / (self.by - self.ay - options.volume_border) + self:set_volume(volume_fraction * state.volume_max) +end + +function VolumeSlider:on_coordinates() + if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end + local width = self.bx - self.ax + self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max)) + self.nudge_size = round(width * 0.18) + self.draw_nudge = self.ay < self.nudge_y + self.spacing = round(width * 0.2) + self.radius = math.max(2, (self.bx - self.ax) / 10) +end +function VolumeSlider:on_mbtn_left_down() + self.pressed = true + self:set_from_cursor() +end +function VolumeSlider:on_global_mbtn_left_up() self.pressed = false end +function VolumeSlider:on_global_mouse_leave() self.pressed = false end +function VolumeSlider:on_global_mouse_move() + if self.pressed then self:set_from_cursor() end +end +function VolumeSlider:on_wheel_up() self:set_volume(state.volume + options.volume_step) end +function VolumeSlider:on_wheel_down() self:set_volume(state.volume - options.volume_step) end + +function VolumeSlider:render() + local visibility = self:get_visibility() + local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by + local width, height = bx - ax, by - ay + + if width <= 0 or height <= 0 or visibility <= 0 then return end + + local ass = assdraw.ass_new() + local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -infinity, self.nudge_size + local volume_y = self.ay + options.volume_border + + ((height - (options.volume_border * 2)) * (1 - math.min(state.volume / state.volume_max, 1))) + + -- Draws a rectangle with nudge at requested position + ---@param p number Padding from slider edges. + ---@param cy? number A y coordinate where to clip the path from the bottom. + function create_nudged_path(p, cy) + cy = cy or ay + p + local ax, bx, by = ax + p, bx - p, by - p + local r = math.max(1, self.radius - p) + local d, rh = r * 2, r / 2 + local nudge_size = ((quarter_pi_sin * (nudge_size - p)) + p) / quarter_pi_sin + local path = assdraw.ass_new() + path:move_to(bx - r, by) + path:line_to(ax + r, by) + if cy > by - d then + local subtracted_radius = (d - (cy - (by - d))) / 2 + local xbd = (r - subtracted_radius * 1.35) -- x bezier delta + path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by) + else + path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r) + local nudge_bottom_y = nudge_y + nudge_size + + if cy + rh <= nudge_bottom_y then + path:line_to(ax, nudge_bottom_y) + if cy <= nudge_y then + path:line_to((ax + nudge_size), nudge_y) + local nudge_top_y = nudge_y - nudge_size + if cy <= nudge_top_y then + local r, rh = r, rh + if cy > nudge_top_y - r then + r = nudge_top_y - cy + rh = r / 2 + end + path:line_to(ax, nudge_top_y) + path:line_to(ax, cy + r) + path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r) + path:line_to(bx, nudge_top_y) + else + local triangle_side = cy - nudge_top_y + path:line_to((ax + triangle_side), cy) + path:line_to((bx - triangle_side), cy) + end + path:line_to((bx - nudge_size), nudge_y) + else + local triangle_side = nudge_bottom_y - cy + path:line_to((ax + triangle_side), cy) + path:line_to((bx - triangle_side), cy) + end + path:line_to(bx, nudge_bottom_y) + else + path:line_to(ax, cy + r) + path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r) + end + path:line_to(bx, by - r) + path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by) + end + return path + end + + -- BG & FG paths + local bg_path = create_nudged_path(0) + local fg_path = create_nudged_path(options.volume_border, volume_y) + + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H' .. options.background .. + '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}') + ass:opacity(math.max(options.volume_opacity - 0.1, 0), visibility) + ass:pos(0, 0) + ass:draw_start() + ass:append(bg_path.text) + ass:draw_stop() + + -- Foreground + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H' .. options.foreground .. '}') + ass:opacity(options.volume_opacity, visibility) + ass:pos(0, 0) + ass:draw_start() + ass:append(fg_path.text) + ass:draw_stop() + + -- Current volume value + local volume_string = tostring(round(state.volume * 10) / 10) + local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale) + if volume_y < self.by - self.spacing then + ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, { + size = font_size, color = options.foreground_text, opacity = visibility, + clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + if volume_y > self.by - self.spacing - font_size then + ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, { + size = font_size, color = options.background_text, opacity = visibility, + clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + + -- Disabled stripes for no audio + if not state.has_audio then + local fg_100_path = create_nudged_path(options.volume_border) + local texture_opts = { + size = 200, color = options.foreground, opacity = visibility * 0.1, anchor_x = ax, + clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')', + } + ass:texture(ax, ay, bx, by, 'a', texture_opts) + texture_opts.color = options.background + texture_opts.anchor_x = ax + texture_opts.size / 28 + ass:texture(ax, ay, bx, by, 'a', texture_opts) + end + + return ass +end + +--[[ Volume ]] + +---@class Volume : Element +local Volume = class(Element) + +function Volume:new() return Class.new(self) --[[@as Volume]] end +function Volume:init() + Element.init(self, 'volume') + self.mute = MuteButton:new({anchor_id = 'volume'}) + self.slider = VolumeSlider:new({anchor_id = 'volume'}) +end + +function Volume:get_visibility() + return self.slider.pressed and 1 or Elements.timeline.proximity_raw == 0 and -1 or Element.get_visibility(self) +end + +function Volume:update_dimensions() + local width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size + local controls, timeline, top_bar = Elements.controls, Elements.timeline, Elements.top_bar + local min_y = top_bar.enabled and top_bar.by or 0 + local max_y = (controls and controls.enabled and controls.ay) or (timeline.enabled and timeline.ay) or 0 + local available_height = max_y - min_y + local max_height = available_height * 0.8 + local height = round(math.min(width * 8, max_height)) + self.enabled = height > width * 2 -- don't render if too small + local margin = (width / 2) + Elements.window_border.size + self.ax = round(options.volume == 'left' and margin or display.width - margin - width) + self.ay = min_y + round((available_height - height) / 2) + self.bx = round(self.ax + width) + self.by = round(self.ay + height) + self.mute:set_coordinates(self.ax, self.by - round(width * 0.8), self.bx, self.by) + self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute.ay) +end + +function Volume:on_display() self:update_dimensions() end +function Volume:on_prop_border() self:update_dimensions() end +function Volume:on_controls_reflow() self:update_dimensions() end + +--[[ Curtain ]] + +---@class Curtain : Element +local Curtain = class(Element) + +function Curtain:new() return Class.new(self) --[[@as Curtain]] end +function Curtain:init() + Element.init(self, 'curtain', {ignores_menu = true}) + self.opacity = 0 +end + +function Curtain:fadeout() self:tween_property('opacity', self.opacity, 0) end +function Curtain:fadein() self:tween_property('opacity', self.opacity, 1) end + +function Curtain:render() + if self.opacity == 0 or options.curtain_opacity == 0 then return end + local ass = assdraw.ass_new() + ass:rect(0, 0, display.width, display.height, { + color = '000000', opacity = options.curtain_opacity * self.opacity, + }) + return ass +end + +--[[ CREATE STATIC ELEMENTS ]] + +WindowBorder:new() +PauseIndicator:new() +Timeline:new() +TopBar:new() +if options.controls and options.controls ~= 'never' then Controls:new() end +if itable_index_of({'left', 'right'}, options.volume) then Volume:new() end +Curtain:new() + +--[[ MENUS ]] + +---@param data MenuData +---@param opts? {submenu?: string; blurred?: boolean} +function open_command_menu(data, opts) + local menu = Menu:open(data, function(value) + if type(value) == 'string' then + mp.command(value) + else + ---@diagnostic disable-next-line: deprecated + mp.commandv((unpack or table.unpack)(value)) + end + end, opts) + if opts and opts.submenu then menu:activate_submenu(opts.submenu) end + return menu +end + +---@param opts? {submenu?: string; blurred?: boolean} +function toggle_menu_with_items(opts) + if Menu:is_open('menu') then Menu:close() + else open_command_menu({type = 'menu', items = config.menu_items}, opts) end +end + +---@param options {type: string; title: string; list_prop: string; list_serializer: fun(name: string, value: any): MenuDataItem[]; active_prop?: string; on_active_prop: fun(name: string, value: any, menu: Menu): integer; on_select: fun(value: any)} +function create_self_updating_menu_opener(options) + return function() + if Menu:is_open(options.type) then Menu:close() return end + local menu + + -- Update active index and playlist content on playlist changes + local ignore_initial_prop = true + local function handle_list_prop_change(name, value) + if ignore_initial_prop then ignore_initial_prop = false + else menu:update_items(options.list_serializer(name, value)) end + end + + local ignore_initial_active = true + local function handle_active_prop_change(name, value) + if ignore_initial_active then ignore_initial_active = false + else options.on_active_prop(name, value, menu) end + end + + local initial_items, selected_index = options.list_serializer( + options.list_prop, + mp.get_property_native(options.list_prop) + ) + + -- Items and active_index are set in the handle_prop_change callback, since adding + -- a property observer triggers its handler immediately, we just let that initialize the items. + menu = Menu:open( + {type = options.type, title = options.title, items = initial_items, selected_index = selected_index}, + options.on_select, { + on_open = function() + mp.observe_property(options.list_prop, 'native', handle_list_prop_change) + if options.active_prop then + mp.observe_property(options.active_prop, 'native', handle_active_prop_change) + end + end, + on_close = function() + mp.unobserve_property(handle_list_prop_change) + mp.unobserve_property(handle_active_prop_change) + end, + }) + end +end + +function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command) + local function serialize_tracklist(_, tracklist) + local items = {} + + if load_command then + items[#items + 1] = { + title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true, + } + end + + local first_item_index = #items + 1 + local active_index = nil + local disabled_item = nil + + -- Add option to disable a subtitle track. This works for all tracks, + -- but why would anyone want to disable audio or video? Better to not + -- let people mistakenly select what is unwanted 99.999% of the time. + -- If I'm mistaken and there is an active need for this, feel free to + -- open an issue. + if track_type == 'sub' then + disabled_item = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true} + items[#items + 1] = disabled_item + end + + for _, track in ipairs(tracklist) do + if track.type == track_type then + local hint_values = { + track.lang and track.lang:upper() or nil, + track['demux-h'] and (track['demux-w'] and track['demux-w'] .. 'x' .. track['demux-h'] + or track['demux-h'] .. 'p'), + track['demux-fps'] and string.format('%.5gfps', track['demux-fps']) or nil, + track.codec, + track['audio-channels'] and track['audio-channels'] .. ' channels' or nil, + track['demux-samplerate'] and string.format('%.3gkHz', track['demux-samplerate'] / 1000) or nil, + track.forced and 'forced' or nil, + track.default and 'default' or nil, + } + local hint_values_filtered = {} + for i = 1, #hint_values do + if hint_values[i] then + hint_values_filtered[#hint_values_filtered + 1] = hint_values[i] + end + end + + items[#items + 1] = { + title = (track.title and track.title or 'Track ' .. track.id), + hint = table.concat(hint_values_filtered, ', '), + value = track.id, + active = track.selected, + } + + if track.selected then + if disabled_item then disabled_item.active = false end + active_index = #items + end + end + end + + return items, active_index or first_item_index + end + + local function selection_handler(value) + if value == '{load}' then + mp.command(load_command) + else + mp.commandv('set', track_prop, value and value or 'no') + + -- If subtitle track was selected, assume user also wants to see it + if value and track_type == 'sub' then + mp.commandv('set', 'sub-visibility', 'yes') + end + end + end + + return create_self_updating_menu_opener({ + title = menu_title, + type = track_type, + list_prop = 'track-list', + list_serializer = serialize_tracklist, + on_select = selection_handler, + }) +end + +---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()} + +-- Opens a file navigation menu with items inside `directory_path`. +---@param directory_path string +---@param handle_select fun(path: string): nil +---@param opts NavigationMenuOptions +function open_file_navigation_menu(directory_path, handle_select, opts) + directory = serialize_path(directory_path) + opts = opts or {} + + if not directory then + msg.error('Couldn\'t serialize path "' .. directory_path .. '.') + return + end + + local directories, dirs_error = utils.readdir(directory.path, 'dirs') + local files, files_error = get_files_in_directory(directory.path, opts.allowed_types) + local is_root = not directory.dirname + + if not files or not directories then + msg.error('Retrieving files from ' .. directory .. ' failed: ' .. (dirs_error or files_error or '')) + return + end + + -- Files are already sorted + table.sort(directories, file_order_comparator) + + -- Pre-populate items with parent directory selector if not at root + -- Each item value is a serialized path table it points to. + local items = {} + + if is_root then + if state.os == 'windows' then + items[#items + 1] = { + title = '..', hint = 'Drives', value = {is_drives = true, is_to_parent = true}, separator = true, + } + end + else + local serialized = serialize_path(directory.dirname) + serialized.is_directory = true; + serialized.is_to_parent = true; + items[#items + 1] = {title = '..', hint = 'parent dir', value = serialized, separator = true} + end + + local items_start_index = #items + 1 + + for _, dir in ipairs(directories) do + local serialized = serialize_path(utils.join_path(directory.path, dir)) + if serialized then + serialized.is_directory = true + items[#items + 1] = {title = serialized.basename, value = serialized, hint = '/'} + end + end + + for _, file in ipairs(files) do + local serialized = serialize_path(utils.join_path(directory.path, file)) + if serialized then + serialized.is_file = true + items[#items + 1] = {title = serialized.basename, value = serialized} + end + end + + for index, item in ipairs(items) do + if not item.value.is_to_parent then + if index == items_start_index then item.selected = true end + + if opts.active_path == item.value.path then + item.active = true + if not opts.selected_path then item.selected = true end + end + + if opts.selected_path == item.value.path then item.selected = true end + end + end + + local menu_data = { + type = opts.type, title = opts.title or directory.basename .. '/', items = items, + on_open = opts.on_open, on_close = opts.on_close, + } + + return Menu:open(menu_data, function(path) + local inheritable_options = { + type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path, + } + + if path.is_drives then + open_drives_menu(function(drive_path) + open_file_navigation_menu(drive_path, handle_select, inheritable_options) + end, {type = inheritable_options.type, title = inheritable_options.title, selected_path = directory.path}) + return + end + + if path.is_directory then + -- Preselect directory we are coming from + if path.is_to_parent then + inheritable_options.selected_path = directory.path + end + + open_file_navigation_menu(path.path, handle_select, inheritable_options) + else + handle_select(path.path) + end + end) +end + +-- Opens a file navigation menu with Windows drives as items. +---@param handle_select fun(path: string): nil +---@param opts? NavigationMenuOptions +function open_drives_menu(handle_select, opts) + opts = opts or {} + local process = mp.command_native({ + name = 'subprocess', + capture_stdout = true, + playback_only = false, + args = {'wmic', 'logicaldisk', 'get', 'name', '/value'}, + }) + local items = {} + + if process.status == 0 then + for _, value in ipairs(split(process.stdout, '\n')) do + local drive = string.match(value, 'Name=([A-Z]:)') + if drive then + local drive_path = normalize_path(drive) + items[#items + 1] = { + title = drive, hint = 'Drive', value = drive_path, + selected = opts.selected_path == drive_path, + active = opts.active_path == drive_path, + } + end + end + else + msg.error(process.stderr) + end + + return Menu:open({type = opts.type, title = opts.title or 'Drives', items = items}, handle_select) +end + +-- EVENT HANDLERS + +function create_state_setter(name, callback) + return function(_, value) + set_state(name, value) + if callback then callback() end + request_render() + end +end + +function set_state(name, value) + state[name] = value + Elements:trigger('prop_' .. name, value) +end + +function update_cursor_position() + cursor.x, cursor.y = mp.get_mouse_pos() + + -- mpv reports initial mouse position on linux as (0, 0), which always + -- displays the top bar, so we hardcode cursor position as infinity until + -- we receive a first real mouse move event with coordinates other than 0,0. + if not state.first_real_mouse_move_received then + if cursor.x > 0 and cursor.y > 0 then + state.first_real_mouse_move_received = true + else + cursor.x = infinity + cursor.y = infinity + end + end + + -- add 0.5 to be in the middle of the pixel + cursor.x = (cursor.x + 0.5) / display.scale_x + cursor.y = (cursor.y + 0.5) / display.scale_y + + Elements:update_proximities() + request_render() +end + +function handle_mouse_leave() + -- Slowly fadeout elements that are currently visible + for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do + local element = Elements[element_name] + if element and element.proximity > 0 then + element:tween_property('forced_visibility', element:get_visibility(), 0, function() + element.forced_visibility = nil + end) + end + end + + cursor.hidden = true + Elements:update_proximities() + Elements:trigger('global_mouse_leave') +end + +function handle_mouse_enter() + cursor.hidden = false + update_cursor_position() + Elements:trigger('global_mouse_enter') +end + +function handle_mouse_move() + -- Handle case when we are in cursor hidden state but not left the actual + -- window (i.e. when autohide simulates mouse_leave). + if cursor.hidden then + handle_mouse_enter() + return + end + + update_cursor_position() + Elements:proximity_trigger('mouse_move') + request_render() + + -- Restart timer that hides UI when mouse is autohidden + if options.autohide then + state.cursor_autohide_timer:kill() + state.cursor_autohide_timer:resume() + end +end + +function handle_file_end() + local resume = false + if not state.loop_file then + if state.has_playlist then resume = state.shuffle and navigate_playlist(1) + else resume = options.autoload and navigate_directory(1) end + end + -- Resume only when navigation happened + if resume then mp.command('set pause no') end +end +local file_end_timer = mp.add_timeout(1, handle_file_end) +file_end_timer:kill() + +function load_file_index_in_current_directory(index) + if not state.path or is_protocol(state.path) then return end + + local serialized = serialize_path(state.path) + if serialized and serialized.dirname then + local files = get_files_in_directory(serialized.dirname, config.media_types) + + if not files then return end + if index < 0 then index = #files + index + 1 end + + if files[index] then + mp.commandv('loadfile', utils.join_path(serialized.dirname, files[index])) + end + end +end + +function update_render_delay(name, fps) + if fps then state.render_delay = 1 / fps end +end + +function observe_display_fps(name, fps) + if fps then + mp.unobserve_property(update_render_delay) + mp.unobserve_property(observe_display_fps) + mp.observe_property('display-fps', 'native', update_render_delay) + end +end + +--[[ HOOKS]] + +-- Mouse movement key binds +local mouse_keybinds = { + {'mouse_move', handle_mouse_move}, + {'mouse_leave', handle_mouse_leave}, + {'mouse_enter', handle_mouse_enter}, +} +if options.pause_on_click_shorter_than > 0 then + -- Cycles pause when click is shorter than `options.pause_on_click_shorter_than` + -- while filtering out double clicks. + local duration_seconds = options.pause_on_click_shorter_than / 1000 + local last_down_event; + local click_timer = mp.add_timeout(duration_seconds, function() + mp.command('cycle pause') + end); + click_timer:kill() + mouse_keybinds[#mouse_keybinds + 1] = {'mbtn_left', function() + if mp.get_time() - last_down_event < duration_seconds then + click_timer:resume() + end + end, function() + if click_timer:is_enabled() then + click_timer:kill() + last_down_event = 0 + else + last_down_event = mp.get_time() + end + end, + } +end +mp.set_key_bindings(mouse_keybinds, 'mouse_movement', 'force') +mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor') + +mp.observe_property('osc', 'bool', function(name, value) if value == true then mp.set_property('osc', 'no') end end) +function update_title(title_template) + if title_template:sub(-6) == ' - mpv' then title_template = title_template:sub(1, -7) end + set_state('title', mp.command_native({'expand-text', title_template})) +end +mp.register_event('file-loaded', function() + set_state('path', normalize_path(mp.get_property_native('path'))) + update_title(mp.get_property_native('title')) +end) +mp.register_event('end-file', function(event) + set_state('title', nil) + if event.reason == 'eof' then + file_end_timer:kill() + handle_file_end() + end +end) +mp.observe_property('title', 'string', function(_, title) + -- Don't change title if there is currently none + if state.title then update_title(title) end +end) +mp.observe_property('playback-time', 'number', create_state_setter('time', function() + -- Create a file-end event that triggers right before file ends + file_end_timer:kill() + if state.duration and state.time then + local remaining = (state.duration - state.time) / state.speed + if remaining < 5 then + local timeout = remaining - 0.02 + if timeout > 0 then + file_end_timer.timeout = timeout + file_end_timer:resume() + else handle_file_end() end + end + end + + update_human_times() + + -- Select current chapter + local current_chapter + if state.time and state.chapters then + _, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, true) + end + set_state('current_chapter', current_chapter) +end)) +mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times)) +mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times)) +mp.observe_property('track-list', 'native', function(name, value) + -- checks the file dispositions + local is_image = false + local types = {sub = 0, audio = 0, video = 0} + for _, track in ipairs(value) do + if track.type == 'video' then + is_image = track.image + if not is_image and not track.albumart then types.video = types.video + 1 end + elseif types[track.type] then types[track.type] = types[track.type] + 1 end + end + set_state('is_audio', types.video == 0 and types.audio > 0) + set_state('is_image', is_image) + set_state('has_audio', types.audio > 0) + set_state('has_many_audio', types.audio > 1) + set_state('has_sub', types.sub > 0) + set_state('has_many_sub', types.sub > 1) + set_state('is_video', types.video > 0) + set_state('has_many_video', types.video > 1) + Elements:trigger('dispositions') +end) +mp.observe_property('chapter-list', 'native', function(_, chapters) + local chapters, chapter_ranges = serialize_chapters(chapters), {} + if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end + set_state('chapters', chapters) + set_state('chapter_ranges', chapter_ranges) + set_state('has_chapter', #chapters > 0) + Elements:trigger('dispositions') +end) +mp.observe_property('border', 'bool', create_state_setter('border')) +mp.observe_property('loop-file', 'native', create_state_setter('loop_file')) +mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a')) +mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b')) +mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos')) +mp.observe_property('playlist-count', 'number', function(_, value) + set_state('playlist_count', value) + set_state('has_playlist', value > 1) + Elements:trigger('dispositions') +end) +mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed)) +mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed)) +mp.observe_property('idle-active', 'bool', create_state_setter('idle')) +mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end)) +mp.observe_property('volume', 'number', create_state_setter('volume')) +mp.observe_property('volume-max', 'number', create_state_setter('volume_max')) +mp.observe_property('mute', 'bool', create_state_setter('mute')) +mp.observe_property('osd-dimensions', 'native', function(name, val) + update_display_dimensions() + request_render() +end) +mp.observe_property('display-hidpi-scale', 'native', create_state_setter('hidpi_scale', update_display_dimensions)) +mp.observe_property('cache', 'native', create_state_setter('cache')) +mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function() + Elements:trigger('dispositions') +end)) +mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state) + local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil + if cache_state then + cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached'] + else cached_ranges = {} end + + if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or + (state.cache == 'auto' and state.is_stream))) then + if state.uncached_ranges then set_state('uncached_ranges', nil) end + return + end + + -- Normalize + local ranges = {} + for _, range in ipairs(cached_ranges) do + ranges[#ranges + 1] = { + math.max(range['start'] or 0, 0), + math.min(range['end'] or state.duration, state.duration), + } + end + table.sort(ranges, function(a, b) return a[1] < b[1] end) + if bof then ranges[1][1] = 0 end + if eof then ranges[#ranges][2] = state.duration end + -- Invert cached ranges into uncached ranges, as that's what we're rendering + local inverted_ranges = {{0, state.duration}} + for _, cached in pairs(ranges) do + inverted_ranges[#inverted_ranges][2] = cached[1] + inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration} + end + uncached_ranges = {} + local last_range = nil + for _, range in ipairs(inverted_ranges) do + if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges + last_range[2] = range[2] + else + if range[2] - range[1] > 0.5 then -- skip short ranges + uncached_ranges[#uncached_ranges + 1] = range + last_range = range + end + end + end + + set_state('uncached_ranges', uncached_ranges) +end) +mp.observe_property('display-fps', 'native', observe_display_fps) +mp.observe_property('estimated-display-fps', 'native', update_render_delay) + +-- KEY BINDABLE FEATURES + +mp.add_key_binding(nil, 'toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end) +mp.add_key_binding(nil, 'toggle-timeline', function() Elements:toggle({'timeline'}) end) +mp.add_key_binding(nil, 'toggle-volume', function() Elements:toggle({'volume'}) end) +mp.add_key_binding(nil, 'toggle-top-bar', function() Elements:toggle({'top_bar'}) end) +mp.add_key_binding(nil, 'toggle-progress', function() + local timeline = Elements.timeline + if timeline.size_min_override then + timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function() + timeline.size_min_override = nil + end) + else + timeline:tween_property('size_min_override', timeline.size_min, 0) + end +end) +mp.add_key_binding(nil, 'flash-timeline', function() + Elements.timeline:flash() +end) +mp.add_key_binding(nil, 'flash-top-bar', function() + Elements.top_bar:flash() +end) +mp.add_key_binding(nil, 'flash-volume', function() + if Elements.volume then Elements.volume:flash() end +end) +mp.add_key_binding(nil, 'flash-speed', function() + if Elements.speed then Elements.speed:flash() end +end) +mp.add_key_binding(nil, 'flash-pause-indicator', function() + Elements.pause_indicator:flash() +end) +mp.add_key_binding(nil, 'decide-pause-indicator', function() + Elements.pause_indicator:decide() +end) +mp.add_key_binding(nil, 'menu', function() toggle_menu_with_items() end) +mp.add_key_binding(nil, 'menu-blurred', function() toggle_menu_with_items({blurred = true}) end) +local track_loaders = { + {name = 'subtitles', prop = 'sub', allowed_types = config.subtitle_types}, + {name = 'audio', prop = 'audio', allowed_types = config.media_types}, + {name = 'video', prop = 'video', allowed_types = config.media_types}, +} +for _, loader in ipairs(track_loaders) do + local menu_type = 'load-' .. loader.name + mp.add_key_binding(nil, menu_type, function() + if Menu:is_open(menu_type) then Menu:close() return end + + local path = state.path + if path then + if is_protocol(path) then + path = false + else + local serialized_path = serialize_path(path) + path = serialized_path ~= nil and serialized_path.dirname or false + end + end + if not path then + path = get_default_directory() + end + open_file_navigation_menu( + path, + function(path) mp.commandv(loader.prop .. '-add', path) end, + {type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types} + ) + end) +end +mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener( + 'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles' +)) +mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener( + 'Audio', 'audio', 'aid', 'script-binding uosc/load-audio' +)) +mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener( + 'Video', 'video', 'vid', 'script-binding uosc/load-video' +)) +mp.add_key_binding(nil, 'playlist', create_self_updating_menu_opener({ + title = 'Playlist', + type = 'playlist', + list_prop = 'playlist', + list_serializer = function(_, playlist) + local items = {} + for index, item in ipairs(playlist) do + local is_url = item.filename:find('://') + local item_title = type(item.title) == 'string' and #item.title > 0 and item.title or false + items[index] = { + title = item_title or (is_url and item.filename or serialize_path(item.filename).basename), + hint = tostring(index), + active = item.current, + value = index, + } + end + return items + end, + on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end, +})) +mp.add_key_binding(nil, 'chapters', create_self_updating_menu_opener({ + title = 'Chapters', + type = 'chapters', + list_prop = 'chapter-list', + list_serializer = function(_, chapters) + local items = {} + chapters = normalize_chapters(chapters) + for _, chapter in ipairs(chapters) do + local item = { + title = chapter.title or '', + hint = mp.format_time(chapter.time), + value = chapter.time, + } + items[#items + 1] = item + end + if not state.time then return items end + for index = #items, 1, -1 do + if state.time >= items[index].value then + items[index].active = true + break + end + end + return items + end, + active_prop = 'playback-time', + on_active_prop = function(_, playback_time, menu) + -- Select first chapter from the end with time lower + -- than current playing position. + local position = playback_time + if not position then + menu:deactivate_items() + return + end + local items = menu.current.items + for index = #items, 1, -1 do + if position >= items[index].value then + menu:activate_unique_index(index) + return + end + end + end, + on_select = function(time) mp.commandv('seek', tostring(time), 'absolute') end, +})) +mp.add_key_binding(nil, 'show-in-directory', function() + -- Ignore URLs + if not state.path or is_protocol(state.path) then return end + + if state.os == 'windows' then + utils.subprocess_detached({args = {'explorer', '/select,', state.path}, cancellable = false}) + elseif state.os == 'macos' then + utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false}) + elseif state.os == 'linux' then + local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false}) + + -- Fallback opens the folder with xdg-open instead + if result.status ~= 0 then + utils.subprocess({args = {'xdg-open', serialize_path(state.path).dirname}, cancellable = false}) + end + end +end) +mp.add_key_binding(nil, 'stream-quality', function() + if Menu:is_open('stream-quality') then Menu:close() return end + + local ytdl_format = mp.get_property_native('ytdl-format') + local items = {} + + for _, height in ipairs(config.stream_quality_options) do + local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']' + items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format} + end + + Menu:open({type = 'stream-quality', title = 'Stream quality', items = items}, function(format) + mp.set_property('ytdl-format', format) + + -- Reload the video to apply new format + -- This is taken from https://github.com/jgreco/mpv-youtube-quality + -- which is in turn taken from https://github.com/4e6/mpv-reload/ + -- Dunno if playlist_pos shenanigans below are necessary. + local playlist_pos = mp.get_property_number('playlist-pos') + local duration = mp.get_property_native('duration') + local time_pos = mp.get_property('time-pos') + + mp.set_property_number('playlist-pos', playlist_pos) + + -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero + -- duration property. When reloading VOD, to keep the current time position + -- we should provide offset from the start. Stream doesn't have fixed start. + -- Decent choice would be to reload stream from it's current 'live' position. + -- That's the reason we don't pass the offset when reloading streams. + 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) +end) +mp.add_key_binding(nil, 'open-file', function() + if Menu:is_open('open-file') then Menu:close() return end + + local directory + local active_file + + if state.path == nil or is_protocol(state.path) then + local serialized = serialize_path(get_default_directory()) + if serialized then + directory = serialized.path + active_file = nil + end + else + local serialized = serialize_path(state.path) + if serialized then + directory = serialized.dirname + active_file = serialized.path + end + end + + if not directory then + msg.error('Couldn\'t serialize path "' .. state.path .. '".') + return + end + + -- Update active file in directory navigation menu + local function handle_file_loaded() + if Menu:is_open('open-file') then + Elements.menu:activate_value(normalize_path(mp.get_property_native('path'))) + end + end + + open_file_navigation_menu( + directory, + function(path) mp.commandv('loadfile', path) end, + { + type = 'open-file', + allowed_types = config.media_types, + active_path = active_file, + on_open = function() mp.register_event('file-loaded', handle_file_loaded) end, + on_close = function() mp.unregister_event(handle_file_loaded) end, + } + ) +end) +mp.add_key_binding(nil, 'shuffle', function() set_state('shuffle', not state.shuffle) end) +mp.add_key_binding(nil, 'items', function() + if state.has_playlist then + mp.command('script-binding uosc/playlist') + else + mp.command('script-binding uosc/open-file') + end +end) +mp.add_key_binding(nil, 'next', function() navigate_item(1) end) +mp.add_key_binding(nil, 'prev', function() navigate_item(-1) end) +mp.add_key_binding(nil, 'next-file', function() navigate_directory(1) end) +mp.add_key_binding(nil, 'prev-file', function() navigate_directory(-1) end) +mp.add_key_binding(nil, 'first', function() + if state.has_playlist then + mp.commandv('set', 'playlist-pos-1', '1') + else + load_file_index_in_current_directory(1) + end +end) +mp.add_key_binding(nil, 'last', function() + if state.has_playlist then + mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count)) + else + load_file_index_in_current_directory(-1) + end +end) +mp.add_key_binding(nil, 'first-file', function() load_file_index_in_current_directory(1) end) +mp.add_key_binding(nil, 'last-file', function() load_file_index_in_current_directory(-1) end) +mp.add_key_binding(nil, 'delete-file-next', function() + local next_file = nil + local is_local_file = state.path and not is_protocol(state.path) + + if is_local_file then + if Menu:is_open('open-file') then Elements.menu:delete_value(state.path) end + end + + if state.has_playlist then + mp.commandv('playlist-remove', 'current') + else + if is_local_file then + local paths, current_index = get_adjacent_paths(state.path, config.media_types) + if paths and current_index then + local index, path = decide_navigation_in_list(paths, current_index, 1) + if path then next_file = path end + end + end + + if next_file then mp.commandv('loadfile', next_file) + else mp.commandv('stop') end + end + + if is_local_file then delete_file(state.path) end +end) +mp.add_key_binding(nil, 'delete-file-quit', function() + mp.command('stop') + if state.path and not is_protocol(state.path) then delete_file(state.path) end + mp.command('quit') +end) +mp.add_key_binding(nil, 'audio-device', create_self_updating_menu_opener({ + title = 'Audio devices', + type = 'audio-device-list', + list_prop = 'audio-device-list', + list_serializer = function(_, audio_device_list) + local current_device = mp.get_property('audio-device') or 'auto' + local ao = mp.get_property('current-ao') or '' + local items = {} + local active_index = nil + for _, device in ipairs(audio_device_list) do + if device.name == 'auto' or string.match(device.name, '^' .. ao) then + local hint = string.match(device.name, ao .. '/(.+)') + if not hint then hint = device.name end + items[#items + 1] = { + title = device.description, + hint = hint, + value = device.name, + } + if device.name == current_device then active_index = #items end + end + end + return items, active_index + end, + on_select = function(name) mp.commandv('set', 'audio-device', name) end, +})) +mp.add_key_binding(nil, 'open-config-directory', function() + local config_path = mp.command_native({'expand-path', '~~/mpv.conf'}) + local config = serialize_path(config_path) + + if config then + local args + + if state.os == 'windows' then + args = {'explorer', '/select,', config.path} + elseif state.os == 'macos' then + args = {'open', '-R', config.path} + elseif state.os == 'linux' then + args = {'xdg-open', config.dirname} + end + + utils.subprocess_detached({args = args, cancellable = false}) + else + msg.error('Couldn\'t serialize config path "' .. config_path .. '".') + end +end) + +-- MESSAGE HANDLERS + +mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end) +mp.register_script_message('get-version', function(script) + mp.commandv('script-message-to', script, 'uosc-version', config.version) +end) +mp.register_script_message('open-menu', function(json, submenu_id) + local data = utils.parse_json(json) + if type(data) ~= 'table' or type(data.items) ~= 'table' then + msg.error('open-menu: received json didn\'t produce a table with menu configuration') + else + if data.type and Menu:is_open(data.type) then Menu:close() + else open_command_menu(data, {submenu_id = submenu_id}) end + end +end) +mp.register_script_message('update-menu', function(json) + local data = utils.parse_json(json) + if type(data) ~= 'table' or type(data.items) ~= 'table' then + msg.error('update-menu: received json didn\'t produce a table with menu configuration') + else + local menu = data.type and Menu:is_open(data.type) + if menu then menu:update(data) + else open_command_menu(data) end + end +end) +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 + thumbnail.disabled = true + msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information') + else + thumbnail = data + end +end) diff --git a/xfce-custom/dots/home/neko/.config/mpv/scripts/webm.lua b/xfce-custom/dots/home/neko/.config/mpv/scripts/webm.lua new file mode 100644 index 0000000..5c67fd0 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/mpv/scripts/webm.lua @@ -0,0 +1,2913 @@ +local mp = require("mp") +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") +local utils = require("mp.utils") +local mpopts = require("mp.options") +local options = { + -- Defaults to shift+w + keybind = "W", + -- If empty, saves on the same directory of the playing video. + -- A starting "~" will be replaced by the home dir. + -- This field is delimited by double-square-brackets - [[ and ]] - instead of + -- quotes, because Windows users might run into a issue when using + -- backslashes as a path separator. Examples of valid inputs for this field + -- would be: [[]] (the default, empty value), [[C:\Users\John]] (on Windows), + -- and [[/home/john]] (on Unix-like systems eg. Linux). + -- The [[]] delimiter is not needed when using from a configuration file + -- in the script-opts folder. + output_directory = [[]], + run_detached = false, + -- Template string for the output file + -- %f - Filename, with extension + -- %F - Filename, without extension + -- %T - Media title, if it exists, or filename, with extension (useful for some streams, such as YouTube). + -- %s, %e - Start and end time, with milliseconds + -- %S, %E - Start and end time, without milliseconds + -- %M - "-audio", if audio is enabled, empty otherwise + -- %R - "-(height)p", where height is the video's height, or scale_height, if it's enabled. + -- More specifiers are supported, see https://mpv.io/manual/master/#options-screenshot-template + -- Property expansion is supported (with %{} at top level, ${} when nested), see https://mpv.io/manual/master/#property-expansion + output_template = "%F-[%s-%e]%M", + -- Scale video to a certain height, keeping the aspect ratio. -1 disables it. + scale_height = -1, + -- Change the FPS of the output video, dropping or duplicating frames as needed. + -- -1 means the FPS will be unchanged from the source. + fps = -1, + -- Target filesize, in kB. This will be used to calculate the bitrate + -- used on the encode. If this is set to <= 0, the video bitrate will be set + -- to 0, which might enable constant quality modes, depending on the + -- video codec that's used (VP8 and VP9, for example). + target_filesize = 2500, + -- If true, will use stricter flags to ensure the resulting file doesn't + -- overshoot the target filesize. Not recommended, as constrained quality + -- mode should work well, unless you're really having trouble hitting + -- the target size. + strict_filesize_constraint = false, + strict_bitrate_multiplier = 0.95, + -- In kilobits. + strict_audio_bitrate = 64, + -- Sets the output format, from a few predefined ones. + -- Currently we have: + -- webm-vp8 (libvpx/libvorbis) + -- webm-vp9 (libvpx-vp9/libopus) + -- mp4 (h264/AAC) + -- mp4-nvenc (h264-NVENC/AAC) + -- raw (rawvideo/pcm_s16le). + -- mp3 (libmp3lame) + -- and gif + output_format = "webm-vp8", + twopass = true, + -- If set, applies the video filters currently used on the playback to the encode. + apply_current_filters = true, + -- If set, writes the video's filename to the "Title" field on the metadata. + write_filename_on_metadata = false, + -- Set the number of encoding threads, for codecs libvpx and libvpx-vp9 + libvpx_threads = 4, + additional_flags = "", + -- Constant Rate Factor (CRF). The value meaning and limits may change, + -- from codec to codec. Set to -1 to disable. + crf = 10, + -- Useful for flags that may impact output filesize, such as qmin, qmax etc + -- Won't be applied when strict_filesize_constraint is on. + non_strict_additional_flags = "", + -- Display the encode progress, in %. Requires run_detached to be disabled. + -- On Windows, it shows a cmd popup. "auto" will display progress on non-Windows platforms. + display_progress = "auto", + -- The font size used in the menu. Isn't used for the notifications (started encode, finished encode etc) + font_size = 28, + margin = 10, + message_duration = 5, + -- gif dither mode, 0-5 for bayer w/ bayer_scale 0-5, 6 for paletteuse default (sierra2_4a) + gif_dither = 2, + -- Force square pixels on output video + -- Some players like recent Firefox versions display videos with non-square pixels with wrong aspect ratio + force_square_pixels = false, +} + +mpopts.read_options(options) +local base64_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +-- encoding +function base64_encode(data) + return ((data:gsub('.', function(x) + local r,b='',x:byte() + for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end + return r; + end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) + if (#x < 6) then return '' end + local c=0 + for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end + return base64_chars:sub(c+1,c+1) + end)..({ '', '==', '=' })[#data%3+1]) +end + +-- decoding +function base64_decode(data) + data = string.gsub(data, '[^'..base64_chars..'=]', '') + return (data:gsub('.', function(x) + if (x == '=') then return '' end + local r,f='',(base64_chars:find(x)-1) + for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end + return r; + end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) + if (#x ~= 8) then return '' end + local c=0 + for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end + return string.char(c) + end)) +end +local emit_event +emit_event = function(event_name, ...) + return mp.commandv("script-message", "webm-" .. tostring(event_name), ...) +end +local test_set_options +test_set_options = function(new_options_json) + local new_options = utils.parse_json(new_options_json) + for k, v in pairs(new_options) do + options[k] = v + end +end +mp.register_script_message("mpv-webm-set-options", test_set_options) +local bold +bold = function(text) + return "{\\b1}" .. tostring(text) .. "{\\b0}" +end +local message +message = function(text, duration) + local ass = mp.get_property_osd("osd-ass-cc/0") + ass = ass .. text + return mp.osd_message(ass, duration or options.message_duration) +end +local append +append = function(a, b) + for _, val in ipairs(b) do + a[#a + 1] = val + end + return a +end +local seconds_to_time_string +seconds_to_time_string = function(seconds, no_ms, full) + if seconds < 0 then + return "unknown" + end + local ret = "" + if not (no_ms) then + ret = string.format(".%03d", seconds * 1000 % 1000) + end + ret = string.format("%02d:%02d%s", math.floor(seconds / 60) % 60, math.floor(seconds) % 60, ret) + if full or seconds > 3600 then + ret = string.format("%d:%s", math.floor(seconds / 3600), ret) + end + return ret +end +local seconds_to_path_element +seconds_to_path_element = function(seconds, no_ms, full) + local time_string = seconds_to_time_string(seconds, no_ms, full) + local _ + time_string, _ = time_string:gsub(":", ".") + return time_string +end +local file_exists +file_exists = function(name) + local info, err = utils.file_info(name) + if info ~= nil then + return true + end + return false +end +local expand_properties +expand_properties = function(text, magic) + if magic == nil then + magic = "$" + end + for prefix, raw, prop, colon, fallback, closing in text:gmatch("%" .. magic .. "{([?!]?)(=?)([^}:]*)(:?)([^}]*)(}*)}") do + local err + local prop_value + local compare_value + local original_prop = prop + local get_property = mp.get_property_osd + if raw == "=" then + get_property = mp.get_property + end + if prefix ~= "" then + for actual_prop, compare in prop:gmatch("(.-)==(.*)") do + prop = actual_prop + compare_value = compare + end + end + if colon == ":" then + prop_value, err = get_property(prop, fallback) + else + prop_value, err = get_property(prop, "(error)") + end + prop_value = tostring(prop_value) + if prefix == "?" then + if compare_value == nil then + prop_value = err == nil and fallback .. closing or "" + else + prop_value = prop_value == compare_value and fallback .. closing or "" + end + prefix = "%" .. prefix + elseif prefix == "!" then + if compare_value == nil then + prop_value = err ~= nil and fallback .. closing or "" + else + prop_value = prop_value ~= compare_value and fallback .. closing or "" + end + else + prop_value = prop_value .. closing + end + if colon == ":" then + local _ + text, _ = text:gsub("%" .. magic .. "{" .. prefix .. raw .. original_prop:gsub("%W", "%%%1") .. ":" .. fallback:gsub("%W", "%%%1") .. closing .. "}", expand_properties(prop_value)) + else + local _ + text, _ = text:gsub("%" .. magic .. "{" .. prefix .. raw .. original_prop:gsub("%W", "%%%1") .. closing .. "}", prop_value) + end + end + return text +end +local format_filename +format_filename = function(startTime, endTime, videoFormat) + local hasAudioCodec = videoFormat.audioCodec ~= "" + local replaceFirst = { + ["%%mp"] = "%%mH.%%mM.%%mS", + ["%%mP"] = "%%mH.%%mM.%%mS.%%mT", + ["%%p"] = "%%wH.%%wM.%%wS", + ["%%P"] = "%%wH.%%wM.%%wS.%%wT" + } + local replaceTable = { + ["%%wH"] = string.format("%02d", math.floor(startTime / (60 * 60))), + ["%%wh"] = string.format("%d", math.floor(startTime / (60 * 60))), + ["%%wM"] = string.format("%02d", math.floor(startTime / 60 % 60)), + ["%%wm"] = string.format("%d", math.floor(startTime / 60)), + ["%%wS"] = string.format("%02d", math.floor(startTime % 60)), + ["%%ws"] = string.format("%d", math.floor(startTime)), + ["%%wf"] = string.format("%s", startTime), + ["%%wT"] = string.sub(string.format("%.3f", startTime % 1), 3), + ["%%mH"] = string.format("%02d", math.floor(endTime / (60 * 60))), + ["%%mh"] = string.format("%d", math.floor(endTime / (60 * 60))), + ["%%mM"] = string.format("%02d", math.floor(endTime / 60 % 60)), + ["%%mm"] = string.format("%d", math.floor(endTime / 60)), + ["%%mS"] = string.format("%02d", math.floor(endTime % 60)), + ["%%ms"] = string.format("%d", math.floor(endTime)), + ["%%mf"] = string.format("%s", endTime), + ["%%mT"] = string.sub(string.format("%.3f", endTime % 1), 3), + ["%%f"] = mp.get_property("filename"), + ["%%F"] = mp.get_property("filename/no-ext"), + ["%%s"] = seconds_to_path_element(startTime), + ["%%S"] = seconds_to_path_element(startTime, true), + ["%%e"] = seconds_to_path_element(endTime), + ["%%E"] = seconds_to_path_element(endTime, true), + ["%%T"] = mp.get_property("media-title"), + ["%%M"] = (mp.get_property_native('aid') and not mp.get_property_native('mute') and hasAudioCodec) and '-audio' or '', + ["%%R"] = (options.scale_height ~= -1) and "-" .. tostring(options.scale_height) .. "p" or "-" .. tostring(mp.get_property_native('height')) .. "p", + ["%%t%%"] = "%%" + } + local filename = options.output_template + for format, value in pairs(replaceFirst) do + local _ + filename, _ = filename:gsub(format, value) + end + for format, value in pairs(replaceTable) do + local _ + filename, _ = filename:gsub(format, value) + end + if mp.get_property_bool("demuxer-via-network", false) then + local _ + filename, _ = filename:gsub("%%X{([^}]*)}", "%1") + filename, _ = filename:gsub("%%x", "") + else + local x = string.gsub(mp.get_property("stream-open-filename", ""), string.gsub(mp.get_property("filename", ""), "%W", "%%%1") .. "$", "") + local _ + filename, _ = filename:gsub("%%X{[^}]*}", x) + filename, _ = filename:gsub("%%x", x) + end + filename = expand_properties(filename, "%") + for format in filename:gmatch("%%t([aAbBcCdDeFgGhHIjmMnprRStTuUVwWxXyYzZ])") do + local _ + filename, _ = filename:gsub("%%t" .. format, os.date("%" .. format)) + end + local _ + filename, _ = filename:gsub("[<>:\"/\\|?*]", "") + return tostring(filename) .. "." .. tostring(videoFormat.outputExtension) +end +local parse_directory +parse_directory = function(dir) + local home_dir = os.getenv("HOME") + if not home_dir then + home_dir = os.getenv("USERPROFILE") + end + 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.warn("Couldn't find home dir.") + home_dir = "" + end + end + local _ + dir, _ = dir:gsub("^~", home_dir) + return dir +end +local is_windows = type(package) == "table" and type(package.config) == "string" and package.config:sub(1, 1) == "\\" +local trim +trim = function(s) + return s:match("^%s*(.-)%s*$") +end +local get_null_path +get_null_path = function() + if file_exists("/dev/null") then + return "/dev/null" + end + return "NUL" +end +local run_subprocess +run_subprocess = function(params) + local res = utils.subprocess(params) + msg.verbose("Command stdout: ") + msg.verbose(res.stdout) + if res.status ~= 0 then + msg.verbose("Command failed! Reason: ", res.error, " Killed by us? ", res.killed_by_us and "yes" or "no") + return false + end + return true +end +local shell_escape +shell_escape = function(args) + local ret = { } + for i, a in ipairs(args) do + local s = tostring(a) + if string.match(s, "[^A-Za-z0-9_/:=-]") then + if is_windows then + s = '"' .. string.gsub(s, '"', '"\\""') .. '"' + else + s = "'" .. string.gsub(s, "'", "'\\''") .. "'" + end + end + table.insert(ret, s) + end + local concat = table.concat(ret, " ") + if is_windows then + concat = '"' .. concat .. '"' + end + return concat +end +local run_subprocess_popen +run_subprocess_popen = function(command_line) + local command_line_string = shell_escape(command_line) + command_line_string = command_line_string .. " 2>&1" + msg.verbose("run_subprocess_popen: running " .. tostring(command_line_string)) + return io.popen(command_line_string) +end +local calculate_scale_factor +calculate_scale_factor = function() + local baseResY = 720 + local osd_w, osd_h = mp.get_osd_size() + return osd_h / baseResY +end +local should_display_progress +should_display_progress = function() + if options.display_progress == "auto" then + return not is_windows + end + return options.display_progress +end +local reverse +reverse = function(list) + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = 1 + for _index_0 = #list, _max_0 < 0 and #list + _max_0 or _max_0, -1 do + local element = list[_index_0] + _accum_0[_len_0] = element + _len_0 = _len_0 + 1 + end + return _accum_0 +end +local get_pass_logfile_path +get_pass_logfile_path = function(encode_out_path) + return tostring(encode_out_path) .. "-video-pass1.log" +end +local dimensions_changed = true +local _video_dimensions = { } +local get_video_dimensions +get_video_dimensions = function() + if not (dimensions_changed) then + return _video_dimensions + end + local video_params = mp.get_property_native("video-out-params") + if not video_params then + return nil + end + dimensions_changed = false + local keep_aspect = mp.get_property_bool("keepaspect") + local w = video_params["w"] + local h = video_params["h"] + local dw = video_params["dw"] + local dh = video_params["dh"] + if mp.get_property_number("video-rotate") % 180 == 90 then + w, h = h, w + dw, dh = dh, dw + end + _video_dimensions = { + top_left = { }, + bottom_right = { }, + ratios = { } + } + local window_w, window_h = mp.get_osd_size() + if keep_aspect then + local unscaled = mp.get_property_native("video-unscaled") + local panscan = mp.get_property_number("panscan") + local fwidth = window_w + local fheight = math.floor(window_w / dw * dh) + if fheight > window_h or fheight < h then + local tmpw = math.floor(window_h / dh * dw) + if tmpw <= window_w then + fheight = window_h + fwidth = tmpw + end + end + local vo_panscan_area = window_h - fheight + local f_w = fwidth / fheight + local f_h = 1 + if vo_panscan_area == 0 then + vo_panscan_area = window_h - fwidth + f_w = 1 + f_h = fheight / fwidth + end + if unscaled or unscaled == "downscale-big" then + vo_panscan_area = 0 + if unscaled or (dw <= window_w and dh <= window_h) then + fwidth = dw + fheight = dh + end + end + local scaled_width = fwidth + math.floor(vo_panscan_area * panscan * f_w) + local scaled_height = fheight + math.floor(vo_panscan_area * panscan * f_h) + local split_scaling + split_scaling = function(dst_size, scaled_src_size, zoom, align, pan) + scaled_src_size = math.floor(scaled_src_size * 2 ^ zoom) + align = (align + 1) / 2 + local dst_start = math.floor((dst_size - scaled_src_size) * align + pan * scaled_src_size) + if dst_start < 0 then + dst_start = dst_start + 1 + end + local dst_end = dst_start + scaled_src_size + if dst_start >= dst_end then + dst_start = 0 + dst_end = 1 + end + return dst_start, dst_end + end + local zoom = mp.get_property_number("video-zoom") + local align_x = mp.get_property_number("video-align-x") + local pan_x = mp.get_property_number("video-pan-x") + _video_dimensions.top_left.x, _video_dimensions.bottom_right.x = split_scaling(window_w, scaled_width, zoom, align_x, pan_x) + local align_y = mp.get_property_number("video-align-y") + local pan_y = mp.get_property_number("video-pan-y") + _video_dimensions.top_left.y, _video_dimensions.bottom_right.y = split_scaling(window_h, scaled_height, zoom, align_y, pan_y) + else + _video_dimensions.top_left.x = 0 + _video_dimensions.bottom_right.x = window_w + _video_dimensions.top_left.y = 0 + _video_dimensions.bottom_right.y = window_h + end + _video_dimensions.ratios.w = w / (_video_dimensions.bottom_right.x - _video_dimensions.top_left.x) + _video_dimensions.ratios.h = h / (_video_dimensions.bottom_right.y - _video_dimensions.top_left.y) + return _video_dimensions +end +local set_dimensions_changed +set_dimensions_changed = function() + dimensions_changed = true +end +local monitor_dimensions +monitor_dimensions = function() + local properties = { + "keepaspect", + "video-out-params", + "video-unscaled", + "panscan", + "video-zoom", + "video-align-x", + "video-pan-x", + "video-align-y", + "video-pan-y", + "osd-width", + "osd-height" + } + for _, p in ipairs(properties) do + mp.observe_property(p, "native", set_dimensions_changed) + end +end +local clamp +clamp = function(min, val, max) + if val <= min then + return min + end + if val >= max then + return max + end + return val +end +local clamp_point +clamp_point = function(top_left, point, bottom_right) + return { + x = clamp(top_left.x, point.x, bottom_right.x), + y = clamp(top_left.y, point.y, bottom_right.y) + } +end +local VideoPoint +do + local _class_0 + local _base_0 = { + set_from_screen = function(self, sx, sy) + local d = get_video_dimensions() + local point = clamp_point(d.top_left, { + x = sx, + y = sy + }, d.bottom_right) + self.x = math.floor(d.ratios.w * (point.x - d.top_left.x) + 0.5) + self.y = math.floor(d.ratios.h * (point.y - d.top_left.y) + 0.5) + end, + to_screen = function(self) + local d = get_video_dimensions() + return { + x = math.floor(self.x / d.ratios.w + d.top_left.x + 0.5), + y = math.floor(self.y / d.ratios.h + d.top_left.y + 0.5) + } + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.x = -1 + self.y = -1 + end, + __base = _base_0, + __name = "VideoPoint" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + VideoPoint = _class_0 +end +local Region +do + local _class_0 + local _base_0 = { + is_valid = function(self) + return self.x > -1 and self.y > -1 and self.w > -1 and self.h > -1 + end, + set_from_points = function(self, p1, p2) + self.x = math.min(p1.x, p2.x) + self.y = math.min(p1.y, p2.y) + self.w = math.abs(p1.x - p2.x) + self.h = math.abs(p1.y - p2.y) + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.x = -1 + self.y = -1 + self.w = -1 + self.h = -1 + end, + __base = _base_0, + __name = "Region" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Region = _class_0 +end +local make_fullscreen_region +make_fullscreen_region = function() + local r = Region() + local d = get_video_dimensions() + local a = VideoPoint() + local b = VideoPoint() + local xa, ya + do + local _obj_0 = d.top_left + xa, ya = _obj_0.x, _obj_0.y + end + a:set_from_screen(xa, ya) + local xb, yb + do + local _obj_0 = d.bottom_right + xb, yb = _obj_0.x, _obj_0.y + end + b:set_from_screen(xb, yb) + r:set_from_points(a, b) + return r +end +local read_double +read_double = function(bytes) + local sign = 1 + local mantissa = bytes[2] % 2 ^ 4 + for i = 3, 8 do + mantissa = mantissa * 256 + bytes[i] + end + if bytes[1] > 127 then + sign = -1 + end + local exponent = (bytes[1] % 128) * 2 ^ 4 + math.floor(bytes[2] / 2 ^ 4) + if exponent == 0 then + return 0 + end + mantissa = (math.ldexp(mantissa, -52) + 1) * sign + return math.ldexp(mantissa, exponent - 1023) +end +local write_double +write_double = function(num) + local bytes = { + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + } + if num == 0 then + return bytes + end + local anum = math.abs(num) + local mantissa, exponent = math.frexp(anum) + exponent = exponent - 1 + mantissa = mantissa * 2 - 1 + local sign = num ~= anum and 128 or 0 + exponent = exponent + 1023 + bytes[1] = sign + math.floor(exponent / 2 ^ 4) + mantissa = mantissa * 2 ^ 4 + local currentmantissa = math.floor(mantissa) + mantissa = mantissa - currentmantissa + bytes[2] = (exponent % 2 ^ 4) * 2 ^ 4 + currentmantissa + for i = 3, 8 do + mantissa = mantissa * 2 ^ 8 + currentmantissa = math.floor(mantissa) + mantissa = mantissa - currentmantissa + bytes[i] = currentmantissa + end + return bytes +end +local FirstpassStats +do + local _class_0 + local duration_multiplier, fields_before_duration, fields_after_duration + local _base_0 = { + get_duration = function(self) + local big_endian_binary_duration = reverse(self.binary_duration) + return read_double(reversed_binary_duration) / duration_multiplier + end, + set_duration = function(self, duration) + local big_endian_binary_duration = write_double(duration * duration_multiplier) + self.binary_duration = reverse(big_endian_binary_duration) + end, + _bytes_to_string = function(self, bytes) + return string.char(unpack(bytes)) + end, + as_binary_string = function(self) + local before_duration_string = self:_bytes_to_string(self.binary_data_before_duration) + local duration_string = self:_bytes_to_string(self.binary_duration) + local after_duration_string = self:_bytes_to_string(self.binary_data_after_duration) + return before_duration_string .. duration_string .. after_duration_string + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self, before_duration, duration, after_duration) + self.binary_data_before_duration = before_duration + self.binary_duration = duration + self.binary_data_after_duration = after_duration + end, + __base = _base_0, + __name = "FirstpassStats" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + local self = _class_0 + duration_multiplier = 10000000.0 + fields_before_duration = 16 + fields_after_duration = 1 + self.data_before_duration_size = function(self) + return fields_before_duration * 8 + end + self.data_after_duration_size = function(self) + return fields_after_duration * 8 + end + self.size = function(self) + return (fields_before_duration + 1 + fields_after_duration) * 8 + end + self.from_bytes = function(self, bytes) + local before_duration + do + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = self:data_before_duration_size() + for _index_0 = 1, _max_0 < 0 and #bytes + _max_0 or _max_0 do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + before_duration = _accum_0 + end + local duration + do + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = self:data_before_duration_size() + 8 + for _index_0 = self:data_before_duration_size() + 1, _max_0 < 0 and #bytes + _max_0 or _max_0 do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + duration = _accum_0 + end + local after_duration + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = self:data_before_duration_size() + 8 + 1, #bytes do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + after_duration = _accum_0 + end + return self(before_duration, duration, after_duration) + end + FirstpassStats = _class_0 +end +local read_logfile_into_stats_array +read_logfile_into_stats_array = function(logfile_path) + local file = assert(io.open(logfile_path, "rb")) + local logfile_string = base64_decode(file:read()) + file:close() + local stats_size = FirstpassStats:size() + assert(logfile_string:len() % stats_size == 0) + local stats = { } + for offset = 1, #logfile_string, stats_size do + local bytes = { + logfile_string:byte(offset, offset + stats_size - 1) + } + assert(#bytes == stats_size) + stats[#stats + 1] = FirstpassStats:from_bytes(bytes) + end + return stats +end +local write_stats_array_to_logfile +write_stats_array_to_logfile = function(stats_array, logfile_path) + local file = assert(io.open(logfile_path, "wb")) + local logfile_string = "" + for _index_0 = 1, #stats_array do + local stat = stats_array[_index_0] + logfile_string = logfile_string .. stat:as_binary_string() + end + file:write(base64_encode(logfile_string)) + return file:close() +end +local vp8_patch_logfile +vp8_patch_logfile = function(logfile_path, encode_total_duration) + local stats_array = read_logfile_into_stats_array(logfile_path) + local average_duration = encode_total_duration / (#stats_array - 1) + for i = 1, #stats_array - 1 do + stats_array[i]:set_duration(average_duration) + end + stats_array[#stats_array]:set_duration(encode_total_duration) + return write_stats_array_to_logfile(stats_array, logfile_path) +end +local formats = { } +local Format +do + local _class_0 + local _base_0 = { + getPreFilters = function(self) + return { } + end, + getPostFilters = function(self) + return { } + end, + getFlags = function(self) + return { } + end, + getCodecFlags = function(self) + local codecs = { } + if self.videoCodec ~= "" then + codecs[#codecs + 1] = "--ovc=" .. tostring(self.videoCodec) + end + if self.audioCodec ~= "" then + codecs[#codecs + 1] = "--oac=" .. tostring(self.audioCodec) + end + return codecs + end, + postCommandModifier = function(self, command, region, startTime, endTime) + return command + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "Basic" + self.supportsTwopass = true + self.videoCodec = "" + self.audioCodec = "" + self.outputExtension = "" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "Format" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Format = _class_0 +end +local RawVideo +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getColorspace = function(self) + local csp = mp.get_property("colormatrix") + local _exp_0 = csp + if "bt.601" == _exp_0 then + return "bt601" + elseif "bt.709" == _exp_0 then + return "bt709" + elseif "bt.2020" == _exp_0 then + return "bt2020" + elseif "smpte-240m" == _exp_0 then + return "smpte240m" + else + msg.info("Warning, unknown colorspace " .. tostring(csp) .. " detected, using bt.601.") + return "bt601" + end + end, + getPostFilters = function(self) + return { + "format=yuv444p16", + "lavfi-scale=in_color_matrix=" .. self:getColorspace(), + "format=bgr24" + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "Raw" + self.supportsTwopass = false + self.videoCodec = "rawvideo" + self.audioCodec = "pcm_s16le" + self.outputExtension = "avi" + self.acceptsBitrate = false + end, + __base = _base_0, + __name = "RawVideo", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + RawVideo = _class_0 +end +formats["raw"] = RawVideo() +local WebmVP8 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getPreFilters = function(self) + local colormatrixFilter = { + ["bt.709"] = "bt709", + ["bt.2020"] = "bt2020", + ["smpte-240m"] = "smpte240m" + } + local ret = { } + local colormatrix = mp.get_property_native("video-params/colormatrix") + if colormatrixFilter[colormatrix] then + append(ret, { + "lavfi-colormatrix=" .. tostring(colormatrixFilter[colormatrix]) .. ":bt601" + }) + end + return ret + end, + getFlags = function(self) + return { + "--ovcopts-add=threads=" .. tostring(options.libvpx_threads), + "--ovcopts-add=auto-alt-ref=1", + "--ovcopts-add=lag-in-frames=25", + "--ovcopts-add=quality=good", + "--ovcopts-add=cpu-used=0" + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "WebM" + self.supportsTwopass = true + self.videoCodec = "libvpx" + self.audioCodec = "libvorbis" + self.outputExtension = "webm" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "WebmVP8", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + WebmVP8 = _class_0 +end +formats["webm-vp8"] = WebmVP8() +local WebmVP9 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getFlags = function(self) + return { + "--ovcopts-add=threads=" .. tostring(options.libvpx_threads) + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "WebM (VP9)" + self.supportsTwopass = false + self.videoCodec = "libvpx-vp9" + self.audioCodec = "libopus" + self.outputExtension = "webm" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "WebmVP9", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + WebmVP9 = _class_0 +end +formats["webm-vp9"] = WebmVP9() +local MP4 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP4 (h264/AAC)" + self.supportsTwopass = true + self.videoCodec = "libx264" + self.audioCodec = "aac" + self.outputExtension = "mp4" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP4", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP4 = _class_0 +end +formats["mp4"] = MP4() +local MP4NVENC +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP4 (h264-NVENC/AAC)" + self.supportsTwopass = true + self.videoCodec = "h264_nvenc" + self.audioCodec = "aac" + self.outputExtension = "mp4" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP4NVENC", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP4NVENC = _class_0 +end +formats["mp4-nvenc"] = MP4NVENC() +local MP3 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP3 (libmp3lame)" + self.supportsTwopass = false + self.videoCodec = "" + self.audioCodec = "libmp3lame" + self.outputExtension = "mp3" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP3", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP3 = _class_0 +end +formats["mp3"] = MP3() +local GIF +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + postCommandModifier = function(self, command, region, startTime, endTime) + local new_command = { } + local start_ts = seconds_to_time_string(startTime, false, true) + local end_ts = seconds_to_time_string(endTime, false, true) + start_ts = start_ts:gsub(":", "\\\\:") + end_ts = end_ts:gsub(":", "\\\\:") + local cfilter = "[vid1]trim=start=" .. tostring(start_ts) .. ":end=" .. tostring(end_ts) .. "[vidtmp];" + if mp.get_property("deinterlace") == "yes" then + cfilter = cfilter .. "[vidtmp]yadif=mode=1[vidtmp];" + end + for _, v in ipairs(command) do + local _continue_0 = false + repeat + if v:match("^%-%-vf%-add=lavfi%-scale") or v:match("^%-%-vf%-add=lavfi%-crop") or v:match("^%-%-vf%-add=fps") or v:match("^%-%-vf%-add=lavfi%-eq") then + local n = v:gsub("^%-%-vf%-add=", ""):gsub("^lavfi%-", "") + cfilter = cfilter .. "[vidtmp]" .. tostring(n) .. "[vidtmp];" + else + if v:match("^%-%-video%-rotate=90") then + cfilter = cfilter .. "[vidtmp]transpose=1[vidtmp];" + else + if v:match("^%-%-video%-rotate=270") then + cfilter = cfilter .. "[vidtmp]transpose=2[vidtmp];" + else + if v:match("^%-%-video%-rotate=180") then + cfilter = cfilter .. "[vidtmp]transpose=1[vidtmp];[vidtmp]transpose=1[vidtmp];" + else + if v:match("^%-%-deinterlace=") then + _continue_0 = true + break + else + append(new_command, { + v + }) + _continue_0 = true + break + end + end + end + end + end + _continue_0 = true + until true + if not _continue_0 then + break + end + end + cfilter = cfilter .. "[vidtmp]split[topal][vidf];" + cfilter = cfilter .. "[topal]palettegen[pal];" + cfilter = cfilter .. "[vidf]fifo[vidf];" + if options.gif_dither == 6 then + cfilter = cfilter .. "[vidf][pal]paletteuse[vo]" + else + cfilter = cfilter .. "[vidf][pal]paletteuse=dither=bayer:bayer_scale=" .. tostring(options.gif_dither) .. ":diff_mode=rectangle[vo]" + end + append(new_command, { + "--lavfi-complex=" .. tostring(cfilter) + }) + return new_command + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "GIF" + self.supportsTwopass = false + self.videoCodec = "gif" + self.audioCodec = "" + self.outputExtension = "gif" + self.acceptsBitrate = false + end, + __base = _base_0, + __name = "GIF", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + GIF = _class_0 +end +formats["gif"] = GIF() +local Page +do + local _class_0 + local _base_0 = { + add_keybinds = function(self) + if not self.keybinds then + return + end + for key, func in pairs(self.keybinds) do + mp.add_forced_key_binding(key, key, func, { + repeatable = true + }) + end + end, + remove_keybinds = function(self) + if not self.keybinds then + return + end + for key, _ in pairs(self.keybinds) do + mp.remove_key_binding(key) + end + end, + observe_properties = function(self) + self.sizeCallback = function() + return self:draw() + end + local properties = { + "keepaspect", + "video-out-params", + "video-unscaled", + "panscan", + "video-zoom", + "video-align-x", + "video-pan-x", + "video-align-y", + "video-pan-y", + "osd-width", + "osd-height" + } + for _index_0 = 1, #properties do + local p = properties[_index_0] + mp.observe_property(p, "native", self.sizeCallback) + end + end, + unobserve_properties = function(self) + if self.sizeCallback then + mp.unobserve_property(self.sizeCallback) + self.sizeCallback = nil + end + end, + clear = function(self) + local window_w, window_h = mp.get_osd_size() + mp.set_osd_ass(window_w, window_h, "") + return mp.osd_message("", 0) + end, + prepare = function(self) + return nil + end, + dispose = function(self) + return nil + end, + show = function(self) + if self.visible then + return + end + self.visible = true + self:observe_properties() + self:add_keybinds() + self:prepare() + self:clear() + return self:draw() + end, + hide = function(self) + if not self.visible then + return + end + self.visible = false + self:unobserve_properties() + self:remove_keybinds() + self:clear() + return self:dispose() + end, + setup_text = function(self, ass) + local scale = calculate_scale_factor() + local margin = options.margin * scale + ass:append("{\\an7}") + ass:pos(margin, margin) + return ass:append("{\\fs" .. tostring(options.font_size * scale) .. "}") + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function() end, + __base = _base_0, + __name = "Page" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Page = _class_0 +end +local EncodeWithProgress +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + draw = function(self) + local progress = 100 * ((self.currentTime - self.startTime) / self.duration) + local progressText = string.format("%d%%", progress) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append("Encoding (" .. tostring(bold(progressText)) .. ")\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + parseLine = function(self, line) + local matchTime = string.match(line, "Encode time[-]pos: ([0-9.]+)") + local matchExit = string.match(line, "Exiting... [(]([%a ]+)[)]") + if matchTime == nil and matchExit == nil then + return + end + if matchTime ~= nil and tonumber(matchTime) > self.currentTime then + self.currentTime = tonumber(matchTime) + end + if matchExit ~= nil then + self.finished = true + self.finishedReason = matchExit + end + end, + startEncode = function(self, command_line) + local copy_command_line + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #command_line do + local arg = command_line[_index_0] + _accum_0[_len_0] = arg + _len_0 = _len_0 + 1 + end + copy_command_line = _accum_0 + end + append(copy_command_line, { + '--term-status-msg=Encode time-pos: ${=time-pos}\\n' + }) + self:show() + local processFd = run_subprocess_popen(copy_command_line) + for line in processFd:lines() do + msg.verbose(string.format('%q', line)) + self:parseLine(line) + self:draw() + end + processFd:close() + self:hide() + if self.finishedReason == "End of file" then + return true + end + return false + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, startTime, endTime) + self.startTime = startTime + self.endTime = endTime + self.duration = endTime - startTime + self.currentTime = startTime + end, + __base = _base_0, + __name = "EncodeWithProgress", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + EncodeWithProgress = _class_0 +end +local get_active_tracks +get_active_tracks = function() + local accepted = { + video = true, + audio = not mp.get_property_bool("mute"), + sub = mp.get_property_bool("sub-visibility") + } + local active = { + video = { }, + audio = { }, + sub = { } + } + for _, track in ipairs(mp.get_property_native("track-list")) do + if track["selected"] and accepted[track["type"]] then + local count = #active[track["type"]] + active[track["type"]][count + 1] = track + end + end + return active +end +local filter_tracks_supported_by_format +filter_tracks_supported_by_format = function(active_tracks, format) + local has_video_codec = format.videoCodec ~= "" + local has_audio_codec = format.audioCodec ~= "" + local supported = { + video = has_video_codec and active_tracks["video"] or { }, + audio = has_audio_codec and active_tracks["audio"] or { }, + sub = has_video_codec and active_tracks["sub"] or { } + } + return supported +end +local append_track +append_track = function(out, track) + local external_flag = { + ["audio"] = "audio-file", + ["sub"] = "sub-file" + } + local internal_flag = { + ["video"] = "vid", + ["audio"] = "aid", + ["sub"] = "sid" + } + if track['external'] and string.len(track['external-filename']) <= 2048 then + return append(out, { + "--" .. tostring(external_flag[track['type']]) .. "=" .. tostring(track['external-filename']) + }) + else + return append(out, { + "--" .. tostring(internal_flag[track['type']]) .. "=" .. tostring(track['id']) + }) + end +end +local append_audio_tracks +append_audio_tracks = function(out, tracks) + local internal_tracks = { } + for _index_0 = 1, #tracks do + local track = tracks[_index_0] + if track['external'] then + append_track(out, track) + else + append(internal_tracks, { + track + }) + end + end + if #internal_tracks > 1 then + local filter_string = "" + for _index_0 = 1, #internal_tracks do + local track = internal_tracks[_index_0] + filter_string = filter_string .. "[aid" .. tostring(track['id']) .. "]" + end + filter_string = filter_string .. "amix[ao]" + return append(out, { + "--lavfi-complex=" .. tostring(filter_string) + }) + else + if #internal_tracks == 1 then + return append_track(out, internal_tracks[1]) + end + end +end +local get_scale_filters +get_scale_filters = function() + local filters = { } + if options.force_square_pixels then + append(filters, { + "lavfi-scale=iw*sar:ih" + }) + end + if options.scale_height > 0 then + append(filters, { + "lavfi-scale=-2:" .. tostring(options.scale_height) + }) + end + return filters +end +local get_fps_filters +get_fps_filters = function() + if options.fps > 0 then + return { + "fps=" .. tostring(options.fps) + } + end + return { } +end +local get_contrast_brightness_and_saturation_filters +get_contrast_brightness_and_saturation_filters = function() + local mpv_brightness = mp.get_property("brightness") + local mpv_contrast = mp.get_property("contrast") + local mpv_saturation = mp.get_property("saturation") + if mpv_brightness == 0 and mpv_contrast == 0 and mpv_saturation == 0 then + return { } + end + local eq_saturation = (mpv_saturation + 100) / 100.0 + local eq_contrast = (mpv_contrast + 100) / 100.0 + local eq_brightness = (mpv_brightness / 50.0 + eq_contrast - 1) / 2.0 + return { + "lavfi-eq=contrast=" .. tostring(eq_contrast) .. ":saturation=" .. tostring(eq_saturation) .. ":brightness=" .. tostring(eq_brightness) + } +end +local append_property +append_property = function(out, property_name, option_name) + option_name = option_name or property_name + local prop = mp.get_property(property_name) + if prop and prop ~= "" then + return append(out, { + "--" .. tostring(option_name) .. "=" .. tostring(prop) + }) + end +end +local append_list_options +append_list_options = function(out, property_name, option_prefix) + option_prefix = option_prefix or property_name + local prop = mp.get_property_native(property_name) + if prop then + for _index_0 = 1, #prop do + local value = prop[_index_0] + append(out, { + "--" .. tostring(option_prefix) .. "-append=" .. tostring(value) + }) + end + end +end +local get_playback_options +get_playback_options = function() + local ret = { } + append_property(ret, "sub-ass-override") + append_property(ret, "sub-ass-force-style") + append_property(ret, "sub-ass-vsfilter-aspect-compat") + append_property(ret, "sub-auto") + append_property(ret, "sub-delay") + append_property(ret, "video-rotate") + append_property(ret, "ytdl-format") + append_property(ret, "deinterlace") + return ret +end +local get_speed_flags +get_speed_flags = function() + local ret = { } + local speed = mp.get_property_native("speed") + if speed ~= 1 then + append(ret, { + "--vf-add=setpts=PTS/" .. tostring(speed), + "--af-add=atempo=" .. tostring(speed), + "--sub-speed=1/" .. tostring(speed) + }) + end + return ret +end +local get_metadata_flags +get_metadata_flags = function() + local title = mp.get_property("filename/no-ext") + return { + "--oset-metadata=title=%" .. tostring(string.len(title)) .. "%" .. tostring(title) + } +end +local apply_current_filters +apply_current_filters = function(filters) + local vf = mp.get_property_native("vf") + msg.verbose("apply_current_filters: got " .. tostring(#vf) .. " currently applied.") + for _index_0 = 1, #vf do + local _continue_0 = false + repeat + local filter = vf[_index_0] + msg.verbose("apply_current_filters: filter name: " .. tostring(filter['name'])) + if filter["enabled"] == false then + _continue_0 = true + break + end + local str = filter["name"] + local params = filter["params"] or { } + for k, v in pairs(params) do + str = str .. ":" .. tostring(k) .. "=%" .. tostring(string.len(v)) .. "%" .. tostring(v) + end + append(filters, { + str + }) + _continue_0 = true + until true + if not _continue_0 then + break + end + end +end +local get_video_filters +get_video_filters = function(format, region) + local filters = { } + append(filters, format:getPreFilters()) + if options.apply_current_filters then + apply_current_filters(filters) + end + if region and region:is_valid() then + append(filters, { + "lavfi-crop=" .. tostring(region.w) .. ":" .. tostring(region.h) .. ":" .. tostring(region.x) .. ":" .. tostring(region.y) + }) + end + append(filters, get_scale_filters()) + append(filters, get_fps_filters()) + append(filters, get_contrast_brightness_and_saturation_filters()) + append(filters, format:getPostFilters()) + return filters +end +local get_video_encode_flags +get_video_encode_flags = function(format, region) + local flags = { } + append(flags, get_playback_options()) + local filters = get_video_filters(format, region) + for _index_0 = 1, #filters do + local f = filters[_index_0] + append(flags, { + "--vf-add=" .. tostring(f) + }) + end + append(flags, get_speed_flags()) + return flags +end +local calculate_bitrate +calculate_bitrate = function(active_tracks, format, length) + if format.videoCodec == "" then + return nil, options.target_filesize * 8 / length + end + local video_kilobits = options.target_filesize * 8 + local audio_kilobits = nil + local has_audio_track = #active_tracks["audio"] > 0 + if options.strict_filesize_constraint and has_audio_track then + audio_kilobits = length * options.strict_audio_bitrate + video_kilobits = video_kilobits - audio_kilobits + end + local video_bitrate = math.floor(video_kilobits / length) + local audio_bitrate = audio_kilobits and math.floor(audio_kilobits / length) or nil + return video_bitrate, audio_bitrate +end +local find_path +find_path = function(startTime, endTime) + local path = mp.get_property('path') + if not path then + return nil, nil, nil, nil, nil + end + local is_stream = not file_exists(path) + local is_temporary = false + if is_stream then + if mp.get_property('file-format') == 'hls' then + path = utils.join_path(parse_directory('~'), 'cache_dump.ts') + mp.command_native({ + 'dump_cache', + seconds_to_time_string(startTime, false, true), + seconds_to_time_string(endTime + 5, false, true), + path + }) + endTime = endTime - startTime + startTime = 0 + is_temporary = true + end + end + return path, is_stream, is_temporary, startTime, endTime +end +local encode +encode = function(region, startTime, endTime) + local format = formats[options.output_format] + local originalStartTime = startTime + local originalEndTime = endTime + local path, is_stream, is_temporary + path, is_stream, is_temporary, startTime, endTime = find_path(startTime, endTime) + if not path then + message("No file is being played") + return + end + local command = { + "mpv", + path, + "--start=" .. seconds_to_time_string(startTime, false, true), + "--end=" .. seconds_to_time_string(endTime, false, true), + "--loop-file=no", + "--no-pause" + } + append(command, format:getCodecFlags()) + local active_tracks = get_active_tracks() + local supported_active_tracks = filter_tracks_supported_by_format(active_tracks, format) + for track_type, tracks in pairs(supported_active_tracks) do + if track_type == "audio" then + append_audio_tracks(command, tracks) + else + for _index_0 = 1, #tracks do + local track = tracks[_index_0] + append_track(command, track) + end + end + end + for track_type, tracks in pairs(supported_active_tracks) do + local _continue_0 = false + repeat + if #tracks > 0 then + _continue_0 = true + break + end + local _exp_0 = track_type + if "video" == _exp_0 then + append(command, { + "--vid=no" + }) + elseif "audio" == _exp_0 then + append(command, { + "--aid=no" + }) + elseif "sub" == _exp_0 then + append(command, { + "--sid=no" + }) + end + _continue_0 = true + until true + if not _continue_0 then + break + end + end + if format.videoCodec ~= "" then + append(command, get_video_encode_flags(format, region)) + end + append(command, format:getFlags()) + if options.write_filename_on_metadata then + append(command, get_metadata_flags()) + end + if format.acceptsBitrate then + if options.target_filesize > 0 then + local length = endTime - startTime + local video_bitrate, audio_bitrate = calculate_bitrate(supported_active_tracks, format, length) + if video_bitrate then + append(command, { + "--ovcopts-add=b=" .. tostring(video_bitrate) .. "k" + }) + end + if audio_bitrate then + append(command, { + "--oacopts-add=b=" .. tostring(audio_bitrate) .. "k" + }) + end + if options.strict_filesize_constraint then + local type = format.videoCodec ~= "" and "ovc" or "oac" + append(command, { + "--" .. tostring(type) .. "opts-add=minrate=" .. tostring(bitrate) .. "k", + "--" .. tostring(type) .. "opts-add=maxrate=" .. tostring(bitrate) .. "k" + }) + end + else + local type = format.videoCodec ~= "" and "ovc" or "oac" + append(command, { + "--" .. tostring(type) .. "opts-add=b=0" + }) + end + end + for token in string.gmatch(options.additional_flags, "[^%s]+") do + command[#command + 1] = token + end + if not options.strict_filesize_constraint then + for token in string.gmatch(options.non_strict_additional_flags, "[^%s]+") do + command[#command + 1] = token + end + if options.crf >= 0 then + append(command, { + "--ovcopts-add=crf=" .. tostring(options.crf) + }) + end + end + local dir = "" + if is_stream then + dir = parse_directory("~") + else + local _ + dir, _ = utils.split_path(path) + end + if options.output_directory ~= "" then + dir = parse_directory(options.output_directory) + end + local formatted_filename = format_filename(originalStartTime, originalEndTime, format) + local out_path = utils.join_path(dir, formatted_filename) + append(command, { + "--o=" .. tostring(out_path) + }) + emit_event("encode-started") + if options.twopass and format.supportsTwopass and not is_stream then + local first_pass_cmdline + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #command do + local arg = command[_index_0] + _accum_0[_len_0] = arg + _len_0 = _len_0 + 1 + end + first_pass_cmdline = _accum_0 + end + append(first_pass_cmdline, { + "--ovcopts-add=flags=+pass1" + }) + message("Starting first pass...") + msg.verbose("First-pass command line: ", table.concat(first_pass_cmdline, " ")) + local res = run_subprocess({ + args = first_pass_cmdline, + cancellable = false + }) + if not res then + message("First pass failed! Check the logs for details.") + emit_event("encode-finished", "fail") + return + end + append(command, { + "--ovcopts-add=flags=+pass2" + }) + if format.videoCodec == "libvpx" then + msg.verbose("Patching libvpx pass log file...") + vp8_patch_logfile(get_pass_logfile_path(out_path), endTime - startTime) + end + end + command = format:postCommandModifier(command, region, startTime, endTime) + msg.info("Encoding to", out_path) + msg.verbose("Command line:", table.concat(command, " ")) + if options.run_detached then + message("Started encode, process was detached.") + return utils.subprocess_detached({ + args = command + }) + else + local res = false + if not should_display_progress() then + message("Started encode...") + res = run_subprocess({ + args = command, + cancellable = false + }) + else + local ewp = EncodeWithProgress(startTime, endTime) + res = ewp:startEncode(command) + end + if res then + message("Encoded successfully! Saved to\\N" .. tostring(bold(out_path))) + emit_event("encode-finished", "success") + else + message("Encode failed! Check the logs for details.") + emit_event("encode-finished", "fail") + end + os.remove(get_pass_logfile_path(out_path)) + if is_temporary then + return os.remove(path) + end + end +end +local CropPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + reset = function(self) + local dimensions = get_video_dimensions() + local xa, ya + do + local _obj_0 = dimensions.top_left + xa, ya = _obj_0.x, _obj_0.y + end + self.pointA:set_from_screen(xa, ya) + local xb, yb + do + local _obj_0 = dimensions.bottom_right + xb, yb = _obj_0.x, _obj_0.y + end + self.pointB:set_from_screen(xb, yb) + if self.visible then + return self:draw() + end + end, + setPointA = function(self) + local posX, posY = mp.get_mouse_pos() + self.pointA:set_from_screen(posX, posY) + if self.visible then + return self:draw() + end + end, + setPointB = function(self) + local posX, posY = mp.get_mouse_pos() + self.pointB:set_from_screen(posX, posY) + if self.visible then + return self:draw() + end + end, + cancel = function(self) + self:hide() + return self.callback(false, nil) + end, + finish = function(self) + local region = Region() + region:set_from_points(self.pointA, self.pointB) + self:hide() + return self.callback(true, region) + end, + draw_box = function(self, ass) + local region = Region() + region:set_from_points(self.pointA:to_screen(), self.pointB:to_screen()) + local d = get_video_dimensions() + ass:new_event() + ass:append("{\\an7}") + ass:pos(0, 0) + ass:append('{\\bord0}') + ass:append('{\\shad0}') + ass:append('{\\c&H000000&}') + ass:append('{\\alpha&H77}') + ass:draw_start() + ass:rect_cw(d.top_left.x, d.top_left.y, region.x, region.y + region.h) + ass:rect_cw(region.x, d.top_left.y, d.bottom_right.x, region.y) + ass:rect_cw(d.top_left.x, region.y + region.h, region.x + region.w, d.bottom_right.y) + ass:rect_cw(region.x + region.w, region.y, d.bottom_right.x, d.bottom_right.y) + return ass:draw_stop() + end, + draw = function(self) + local window = { } + window.w, window.h = mp.get_osd_size() + local ass = assdraw.ass_new() + self:draw_box(ass) + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('Crop:')) .. "\\N") + ass:append(tostring(bold('1:')) .. " change point A (" .. tostring(self.pointA.x) .. ", " .. tostring(self.pointA.y) .. ")\\N") + ass:append(tostring(bold('2:')) .. " change point B (" .. tostring(self.pointB.x) .. ", " .. tostring(self.pointB.y) .. ")\\N") + ass:append(tostring(bold('r:')) .. " reset to whole screen\\N") + ass:append(tostring(bold('ESC:')) .. " cancel crop\\N") + local width, height = math.abs(self.pointA.x - self.pointB.x), math.abs(self.pointA.y - self.pointB.y) + ass:append(tostring(bold('ENTER:')) .. " confirm crop (" .. tostring(width) .. "x" .. tostring(height) .. ")\\N") + return mp.set_osd_ass(window.w, window.h, ass.text) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback, region) + self.pointA = VideoPoint() + self.pointB = VideoPoint() + self.keybinds = { + ["1"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setPointA + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["2"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setPointB + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["r"] = (function() + local _base_1 = self + local _fn_0 = _base_1.reset + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancel + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ENTER"] = (function() + local _base_1 = self + local _fn_0 = _base_1.finish + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self:reset() + self.callback = callback + if region and region:is_valid() then + self.pointA.x = region.x + self.pointA.y = region.y + self.pointB.x = region.x + region.w + self.pointB.y = region.y + region.h + end + end, + __base = _base_0, + __name = "CropPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + CropPage = _class_0 +end +local Option +do + local _class_0 + local _base_0 = { + hasPrevious = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return true + elseif "int" == _exp_0 then + if self.opts.min then + return self.value > self.opts.min + else + return true + end + elseif "list" == _exp_0 then + return self.value > 1 + end + end, + hasNext = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return true + elseif "int" == _exp_0 then + if self.opts.max then + return self.value < self.opts.max + else + return true + end + elseif "list" == _exp_0 then + return self.value < #self.opts.possibleValues + end + end, + leftKey = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = not self.value + elseif "int" == _exp_0 then + self.value = self.value - self.opts.step + if self.opts.min and self.opts.min > self.value then + self.value = self.opts.min + end + elseif "list" == _exp_0 then + if self.value > 1 then + self.value = self.value - 1 + end + end + end, + rightKey = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = not self.value + elseif "int" == _exp_0 then + self.value = self.value + self.opts.step + if self.opts.max and self.opts.max < self.value then + self.value = self.opts.max + end + elseif "list" == _exp_0 then + if self.value < #self.opts.possibleValues then + self.value = self.value + 1 + end + end + end, + getValue = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return self.value + elseif "int" == _exp_0 then + return self.value + elseif "list" == _exp_0 then + local value, _ + do + local _obj_0 = self.opts.possibleValues[self.value] + value, _ = _obj_0[1], _obj_0[2] + end + return value + end + end, + setValue = function(self, value) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = value + elseif "int" == _exp_0 then + self.value = value + elseif "list" == _exp_0 then + local set = false + for i, possiblePair in ipairs(self.opts.possibleValues) do + local possibleValue, _ + possibleValue, _ = possiblePair[1], possiblePair[2] + if possibleValue == value then + set = true + self.value = i + break + end + end + if not set then + return msg.warn("Tried to set invalid value " .. tostring(value) .. " to " .. tostring(self.displayText) .. " option.") + end + end + end, + getDisplayValue = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return self.value and "yes" or "no" + elseif "int" == _exp_0 then + if self.opts.altDisplayNames and self.opts.altDisplayNames[self.value] then + return self.opts.altDisplayNames[self.value] + else + return tostring(self.value) + end + elseif "list" == _exp_0 then + local value, displayValue + do + local _obj_0 = self.opts.possibleValues[self.value] + value, displayValue = _obj_0[1], _obj_0[2] + end + return displayValue or value + end + end, + draw = function(self, ass, selected) + if selected then + ass:append(tostring(bold(self.displayText)) .. ": ") + else + ass:append(tostring(self.displayText) .. ": ") + end + if self:hasPrevious() then + ass:append("◀ ") + end + ass:append(self:getDisplayValue()) + if self:hasNext() then + ass:append(" ▶") + end + return ass:append("\\N") + end, + optVisible = function(self) + if self.visibleCheckFn == nil then + return true + else + return self.visibleCheckFn() + end + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self, optType, displayText, value, opts, visibleCheckFn) + self.optType = optType + self.displayText = displayText + self.opts = opts + self.value = 1 + self.visibleCheckFn = visibleCheckFn + return self:setValue(value) + end, + __base = _base_0, + __name = "Option" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Option = _class_0 +end +local EncodeOptionsPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + getCurrentOption = function(self) + return self.options[self.currentOption][2] + end, + leftKey = function(self) + (self:getCurrentOption()):leftKey() + return self:draw() + end, + rightKey = function(self) + (self:getCurrentOption()):rightKey() + return self:draw() + end, + prevOpt = function(self) + for i = self.currentOption - 1, 1, -1 do + if self.options[i][2]:optVisible() then + self.currentOption = i + break + end + end + return self:draw() + end, + nextOpt = function(self) + for i = self.currentOption + 1, #self.options do + if self.options[i][2]:optVisible() then + self.currentOption = i + break + end + end + return self:draw() + end, + confirmOpts = function(self) + for _, optPair in ipairs(self.options) do + local optName, opt + optName, opt = optPair[1], optPair[2] + options[optName] = opt:getValue() + end + self:hide() + return self.callback(true) + end, + cancelOpts = function(self) + self:hide() + return self.callback(false) + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('Options:')) .. "\\N\\N") + for i, optPair in ipairs(self.options) do + local opt = optPair[2] + if opt:optVisible() then + opt:draw(ass, self.currentOption == i) + end + end + ass:append("\\N▲ / ▼: navigate\\N") + ass:append(tostring(bold('ENTER:')) .. " confirm options\\N") + ass:append(tostring(bold('ESC:')) .. " cancel\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback) + self.callback = callback + self.currentOption = 1 + local scaleHeightOpts = { + possibleValues = { + { + -1, + "no" + }, + { + 144 + }, + { + 240 + }, + { + 360 + }, + { + 480 + }, + { + 540 + }, + { + 720 + }, + { + 1080 + }, + { + 1440 + }, + { + 2160 + } + } + } + local filesizeOpts = { + step = 250, + min = 0, + altDisplayNames = { + [0] = "0 (constant quality)" + } + } + local crfOpts = { + step = 1, + min = -1, + altDisplayNames = { + [-1] = "disabled" + } + } + local fpsOpts = { + possibleValues = { + { + -1, + "source" + }, + { + 15 + }, + { + 24 + }, + { + 30 + }, + { + 48 + }, + { + 50 + }, + { + 60 + }, + { + 120 + }, + { + 240 + } + } + } + local formatIds = { + "webm-vp8", + "webm-vp9", + "mp4", + "mp4-nvenc", + "raw", + "mp3", + "gif" + } + local formatOpts = { + possibleValues = (function() + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #formatIds do + local fId = formatIds[_index_0] + _accum_0[_len_0] = { + fId, + formats[fId].displayName + } + _len_0 = _len_0 + 1 + end + return _accum_0 + end)() + } + local gifDitherOpts = { + possibleValues = { + { + 0, + "bayer_scale 0" + }, + { + 1, + "bayer_scale 1" + }, + { + 2, + "bayer_scale 2" + }, + { + 3, + "bayer_scale 3" + }, + { + 4, + "bayer_scale 4" + }, + { + 5, + "bayer_scale 5" + }, + { + 6, + "sierra2_4a" + } + } + } + self.options = { + { + "output_format", + Option("list", "Output Format", options.output_format, formatOpts) + }, + { + "twopass", + Option("bool", "Two Pass", options.twopass) + }, + { + "apply_current_filters", + Option("bool", "Apply Current Video Filters", options.apply_current_filters) + }, + { + "scale_height", + Option("list", "Scale Height", options.scale_height, scaleHeightOpts) + }, + { + "strict_filesize_constraint", + Option("bool", "Strict Filesize Constraint", options.strict_filesize_constraint) + }, + { + "write_filename_on_metadata", + Option("bool", "Write Filename on Metadata", options.write_filename_on_metadata) + }, + { + "target_filesize", + Option("int", "Target Filesize", options.target_filesize, filesizeOpts) + }, + { + "crf", + Option("int", "CRF", options.crf, crfOpts) + }, + { + "fps", + Option("list", "FPS", options.fps, fpsOpts) + }, + { + "gif_dither", + Option("list", "GIF Dither Type", options.gif_dither, gifDitherOpts, function() + return self.options[1][2]:getValue() == "gif" + end) + }, + { + "force_square_pixels", + Option("bool", "Force Square Pixels", options.force_square_pixels) + } + } + self.keybinds = { + ["LEFT"] = (function() + local _base_1 = self + local _fn_0 = _base_1.leftKey + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["RIGHT"] = (function() + local _base_1 = self + local _fn_0 = _base_1.rightKey + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["UP"] = (function() + local _base_1 = self + local _fn_0 = _base_1.prevOpt + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["DOWN"] = (function() + local _base_1 = self + local _fn_0 = _base_1.nextOpt + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ENTER"] = (function() + local _base_1 = self + local _fn_0 = _base_1.confirmOpts + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancelOpts + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + end, + __base = _base_0, + __name = "EncodeOptionsPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + EncodeOptionsPage = _class_0 +end +local PreviewPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + prepare = function(self) + local vf = mp.get_property_native("vf") + vf[#vf + 1] = { + name = "sub" + } + if self.region:is_valid() then + vf[#vf + 1] = { + name = "crop", + params = { + w = tostring(self.region.w), + h = tostring(self.region.h), + x = tostring(self.region.x), + y = tostring(self.region.y) + } + } + end + mp.set_property_native("vf", vf) + if self.startTime > -1 and self.endTime > -1 then + mp.set_property_native("ab-loop-a", self.startTime) + mp.set_property_native("ab-loop-b", self.endTime) + mp.set_property_native("time-pos", self.startTime) + end + return mp.set_property_native("pause", false) + end, + dispose = function(self) + mp.set_property("ab-loop-a", "no") + mp.set_property("ab-loop-b", "no") + for prop, value in pairs(self.originalProperties) do + mp.set_property_native(prop, value) + end + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append("Press " .. tostring(bold('ESC')) .. " to exit preview.\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + cancel = function(self) + self:hide() + return self.callback() + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback, region, startTime, endTime) + self.callback = callback + self.originalProperties = { + ["vf"] = mp.get_property_native("vf"), + ["time-pos"] = mp.get_property_native("time-pos"), + ["pause"] = mp.get_property_native("pause") + } + self.keybinds = { + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancel + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self.region = region + self.startTime = startTime + self.endTime = endTime + self.isLoop = false + end, + __base = _base_0, + __name = "PreviewPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + PreviewPage = _class_0 +end +local MainPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + setStartTime = function(self) + self.startTime = mp.get_property_number("time-pos") + if self.visible then + self:clear() + return self:draw() + end + end, + setEndTime = function(self) + self.endTime = mp.get_property_number("time-pos") + if self.visible then + self:clear() + return self:draw() + end + end, + setupStartAndEndTimes = function(self) + if mp.get_property_native("duration") then + self.startTime = 0 + self.endTime = mp.get_property_native("duration") + else + self.startTime = -1 + self.endTime = -1 + end + if self.visible then + self:clear() + return self:draw() + end + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('WebM maker')) .. "\\N\\N") + ass:append(tostring(bold('c:')) .. " crop\\N") + ass:append(tostring(bold('1:')) .. " set start time (current is " .. tostring(seconds_to_time_string(self.startTime)) .. ")\\N") + ass:append(tostring(bold('2:')) .. " set end time (current is " .. tostring(seconds_to_time_string(self.endTime)) .. ")\\N") + ass:append(tostring(bold('o:')) .. " change encode options\\N") + ass:append(tostring(bold('p:')) .. " preview\\N") + ass:append(tostring(bold('e:')) .. " encode\\N\\N") + ass:append(tostring(bold('ESC:')) .. " close\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + show = function(self) + _class_0.__parent.show(self) + return emit_event("show-main-page") + end, + onUpdateCropRegion = function(self, updated, newRegion) + if updated then + self.region = newRegion + end + return self:show() + end, + crop = function(self) + self:hide() + local cropPage = CropPage((function() + local _base_1 = self + local _fn_0 = _base_1.onUpdateCropRegion + return function(...) + return _fn_0(_base_1, ...) + end + end)(), self.region) + return cropPage:show() + end, + onOptionsChanged = function(self, updated) + return self:show() + end, + changeOptions = function(self) + self:hide() + local encodeOptsPage = EncodeOptionsPage((function() + local _base_1 = self + local _fn_0 = _base_1.onOptionsChanged + return function(...) + return _fn_0(_base_1, ...) + end + end)()) + return encodeOptsPage:show() + end, + onPreviewEnded = function(self) + return self:show() + end, + preview = function(self) + self:hide() + local previewPage = PreviewPage((function() + local _base_1 = self + local _fn_0 = _base_1.onPreviewEnded + return function(...) + return _fn_0(_base_1, ...) + end + end)(), self.region, self.startTime, self.endTime) + return previewPage:show() + end, + encode = function(self) + self:hide() + if self.startTime < 0 then + message("No start time, aborting") + return + end + if self.endTime < 0 then + message("No end time, aborting") + return + end + if self.startTime >= self.endTime then + message("Start time is ahead of end time, aborting") + return + end + return encode(self.region, self.startTime, self.endTime) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.keybinds = { + ["c"] = (function() + local _base_1 = self + local _fn_0 = _base_1.crop + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["1"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setStartTime + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["2"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setEndTime + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["o"] = (function() + local _base_1 = self + local _fn_0 = _base_1.changeOptions + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["p"] = (function() + local _base_1 = self + local _fn_0 = _base_1.preview + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["e"] = (function() + local _base_1 = self + local _fn_0 = _base_1.encode + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.hide + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self.startTime = -1 + self.endTime = -1 + self.region = Region() + end, + __base = _base_0, + __name = "MainPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MainPage = _class_0 +end +monitor_dimensions() +local mainPage = MainPage() +mp.add_key_binding(options.keybind, "display-webm-encoder", (function() + local _base_0 = mainPage + local _fn_0 = _base_0.show + return function(...) + return _fn_0(_base_0, ...) + end +end)(), { + repeatable = false +}) +mp.register_event("file-loaded", (function() + local _base_0 = mainPage + local _fn_0 = _base_0.setupStartAndEndTimes + return function(...) + return _fn_0(_base_0, ...) + end +end)()) +msg.verbose("Loaded mpv-webm script!") +return emit_event("script-loaded") diff --git a/xfce-custom/dots/home/neko/.config/xfce4/helpers.rc b/xfce-custom/dots/home/neko/.config/xfce4/helpers.rc new file mode 100644 index 0000000..c18615e --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/helpers.rc @@ -0,0 +1,3 @@ +WebBrowser=custom-WebBrowser +TerminalEmulator=xfce4-terminal + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/displays.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/displays.xml new file mode 100644 index 0000000..0dc41fc --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/displays.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/thunar.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/thunar.xml new file mode 100644 index 0000000..7c797d4 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/thunar.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-keyboard-shortcuts.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-keyboard-shortcuts.xml new file mode 100644 index 0000000..3516122 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-keyboard-shortcuts.xml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-mime-settings.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-mime-settings.xml new file mode 100644 index 0000000..33c5846 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-mime-settings.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-panel.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-panel.xml new file mode 100644 index 0000000..91f5d2b --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-panel.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-settings-manager.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-settings-manager.xml new file mode 100644 index 0000000..2e112e3 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfce4-settings-manager.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfwm4.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfwm4.xml new file mode 100644 index 0000000..0d21099 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xfwm4.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xsettings.xml b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xsettings.xml new file mode 100644 index 0000000..8dceb05 --- /dev/null +++ b/xfce-custom/dots/home/neko/.config/xfce4/xfconf/xsettings.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xfce-custom/dots/home/neko/.local/share/xfce4/helpers/custom-WebBrowser.desktop b/xfce-custom/dots/home/neko/.local/share/xfce4/helpers/custom-WebBrowser.desktop new file mode 100644 index 0000000..838a178 --- /dev/null +++ b/xfce-custom/dots/home/neko/.local/share/xfce4/helpers/custom-WebBrowser.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +NoDisplay=true +Version=1.0 +Encoding=UTF-8 +Type=X-XFCE-Helper +X-XFCE-Category=WebBrowser +X-XFCE-CommandsWithParameter=librewolf "%s" +Icon=librewolf +Name=librewolf +X-XFCE-Commands=librewolf + diff --git a/xfce-custom/dots/home/neko/iwazhere.txt b/xfce-custom/dots/home/neko/iwazhere.txt index 0a1be2e..9963fbe 100644 --- a/xfce-custom/dots/home/neko/iwazhere.txt +++ b/xfce-custom/dots/home/neko/iwazhere.txt @@ -1 +1,3 @@ hello from the other side :3 <3 + +meow - starlight