diff options
author | Joe <bousset.rudy@gmail.com> | 2023-01-31 21:13:10 +0100 |
---|---|---|
committer | Joe <bousset.rudy@gmail.com> | 2023-01-31 21:13:10 +0100 |
commit | ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f (patch) | |
tree | 7341031ed0f84167350e95f48397761ef6544ad9 /.config/mpv/scripts/uosc_shared/lib/utils.lua | |
parent | fbsd merge (diff) | |
parent | up (diff) | |
download | dotfiles-bsd-ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f.tar.gz dotfiles-bsd-ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f.tar.bz2 dotfiles-bsd-ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f.tar.xz dotfiles-bsd-ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f.tar.zst dotfiles-bsd-ae83bf5ca77c4cb3b6f219456e92d4c5e0f8fc9f.zip |
Merge branch 'master' of gitjoe.xyz:dotfiles-bsd
Diffstat (limited to '.config/mpv/scripts/uosc_shared/lib/utils.lua')
-rw-r--r-- | .config/mpv/scripts/uosc_shared/lib/utils.lua | 545 |
1 files changed, 545 insertions, 0 deletions
diff --git a/.config/mpv/scripts/uosc_shared/lib/utils.lua b/.config/mpv/scripts/uosc_shared/lib/utils.lua new file mode 100644 index 0000000..3202ef1 --- /dev/null +++ b/.config/mpv/scripts/uosc_shared/lib/utils.lua @@ -0,0 +1,545 @@ +--[[ UI specific utilities that might or might not depend on its state or options ]] + +-- Sorting comparator close to (but not exactly) how file explorers sort files. +sort_filenames = (function() + local symbol_order + local default_order + + if state.os == 'windows' 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 + + -- Alphanumeric sorting for humans in Lua + -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua + local function pad_number(d) + local dec, n = d:match('(%.?)0*(.+)') + return #dec > 0 and ('%.12f'):format(d) or ('%03d%s'):format(#n, n) + end + + --- In place sorting of filenames + ---@param filenames string[] + return function(filenames) + local tuples = {} + for i, filename in ipairs(filenames) do + local first_char = filename:sub(1, 1) + local order = symbol_order[first_char] or default_order + local formatted = filename:lower():gsub('%.?%d+', pad_number) + tuples[i] = {order, formatted, filename} + end + table.sort(tuples, function(a, b) + if a[1] ~= b[1] then return a[1] < b[1] end + return a[2] == b[2] and #b[3] < #a[3] or a[2] < b[2] + end) + for i, tuple in ipairs(tuples) do filenames[i] = tuple[3] 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 + +-- Extracts the properties used by property expansion of that string. +---@param str string +---@param res { [string] : boolean } | nil +---@return { [string] : boolean } +function get_expansion_props(str, res) + res = res or {} + for str in str:gmatch('%$(%b{})') do + local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$') + if name then + local s = name:find('==') or nil + if s then name = name:sub(0, s - 1) end + res[name] = true + if str and str ~= '' then get_expansion_props(str, res) end + end + end + return res +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 + +path_separator = (function() + local os_separator = state.os == 'windows' and '\\' or '/' + + -- Get appropriate path separator for the given path. + ---@param path string + ---@return string + return function(path) + return path:sub(1, 2) == '\\\\' and '\\' or os_separator + end +end)() + +-- Joins paths with the OS aware path separator or UNC separator. +---@param p1 string +---@param p2 string +---@return string +function join_path(p1, p2) + local p1, separator = trim_trailing_separator(p1) + -- Prevents joining drive letters with a redundant separator (`C:\\foo`), + -- as `trim_trailing_separator()` doesn't trim separators from drive letters. + return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator.. p2 +end + +-- Check if path is absolute. +---@param path string +---@return boolean +function is_absolute(path) + if path:sub(1, 2) == '\\\\' then return true + elseif state.os == 'windows' then return path:find('^%a+:') ~= nil + else return path:sub(1, 1) == '/' end +end + +-- Ensure path is absolute. +---@param path string +---@return string +function ensure_absolute(path) + if is_absolute(path) then return path end + return join_path(state.cwd, path) +end + +-- Remove trailing slashes/backslashes. +---@param path string +---@return string path, string trimmed_separator_type +function trim_trailing_separator(path) + local separator = path_separator(path) + path = trim_end(path, separator) + if state.os == 'windows' then + -- Drive letters on windows need trailing backslash + if path:sub(#path) == ':' then path = path .. '\\' end + else + if path == '' then path = '/' end + end + return path, separator +end + +-- Ensures path is absolute, remove trailing slashes/backslashes. +-- Lightweight version of normalize_path for performance critical parts. +---@param path string +---@return string +function normalize_path_lite(path) + if not path or is_protocol(path) then return path end + path = trim_trailing_separator(ensure_absolute(path)) + return path +end + +-- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication. +---@param path string +---@return string +function normalize_path(path) + if not path or is_protocol(path) then return path end + + path = ensure_absolute(path) + local is_unc = path:sub(1, 2) == '\\\\' + if state.os == 'windows' or is_unc then path = path:gsub('/', '\\') end + path = trim_trailing_separator(path) + + --Deduplication of path separators + if is_unc then path = path:gsub('(.\\)\\+', '%1') + elseif state.os == 'windows' then path = path:gsub('\\\\+', '\\') + else path = path:gsub('//+', '/') end + + return path +end + +-- Check if path is a protocol, such as `http://...`. +---@param path string +function is_protocol(path) + return type(path) == 'string' and (path:find('^%a[%a%d-_]+://') ~= nil or path:find('^%a[%a%d-_]+:\\?') ~= nil) +end + +---@param path string +---@param extensions string[] Lowercase extensions without the dot. +function has_any_extension(path, extensions) + local path_last_dot_index = string_last_index_of(path, '.') + if not path_last_dot_index then return false end + local path_extension = path:sub(path_last_dot_index + 1):lower() + for _, extension in ipairs(extensions) do + if path_extension == extension then return true end + end + return false +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_lite(path) + local dirname, basename = utils.split_path(normal_path) + if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end + local dot_i = string_last_index_of(basename, '.') + + return { + path = normal_path, + is_root = dirname == nil, + dirname = dirname, + basename = basename, + filename = dot_i and basename:sub(1, dot_i - 1) or basename, + extension = dot_i and basename:sub(dot_i + 1) or nil, + } +end + +-- Reads items in directory and splits it into directories and files tables. +---@param path string +---@param allowed_types? string[] Filter `files` table to contain only files with these extensions. +---@return string[]|nil files +---@return string[]|nil directories +function read_directory(path, allowed_types) + local items, error = utils.readdir(path, 'all') + + if not items then + msg.error('Reading files from "' .. path .. '" failed: ' .. error) + return nil, nil + end + + local files, directories = {}, {} + + for _, item in ipairs(items) do + if item ~= '.' and item ~= '..' then + local info = utils.file_info(join_path(path, item)) + if info then + if info.is_file then + if not allowed_types or has_any_extension(item, allowed_types) then + files[#files + 1] = item + end + else directories[#directories + 1] = item end + end + end + end + + return files, directories +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_files(file_path, allowed_types) + local current_file = serialize_path(file_path) + if not current_file then return end + local files = read_directory(current_file.dirname, allowed_types) + if not files then return end + sort_filenames(files) + local current_file_index + local paths = {} + for index, file in ipairs(files) do + paths[#paths + 1] = 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 + math.randomseed(os.time()) + 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_files(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$', 'closing$'}}, + {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]) + 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 -- ad range from sponsorblock + 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 + end_chapter.is_end_only = true + break + end + end -- single chapter for ad + elseif not chapter.is_end_only and + (chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then + local next_chapter = chapters[i + 1] + ranges[#ranges + 1] = table_assign({ + start = chapter.time, + ['end'] = next_chapter and next_chapter.time or infinity, + }, config.chapter_ranges.ads) + 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 + --- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering + local opts = {size = 1, bold = true} + for index, chapter in ipairs(chapters) do + chapter.index = index + chapter.title_wrapped = wrap_text(chapter.title, opts, 25) + chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts) + chapter.title_wrapped = ass_escape(chapter.title_wrapped) + end + return chapters +end + +--[[ RENDERING ]] + +function render() + if not display.initialized then return end + 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 + +-- Request that render() is called. +-- The render is then either executed immediately, or rate-limited if it was +-- called a small time ago. +state.render_timer = mp.add_timeout(0, render) +state.render_timer:kill() +function request_render() + if state.render_timer:is_enabled() then return end + local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time)) + state.render_timer.timeout = timeout + state.render_timer:resume() +end |