dotfiles/multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua

609 lines
20 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--[[ 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.platform == '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(n, d)
return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #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('0*(%d+)%.?(%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
---@param point_a {x: number; y: number}
---@param point_b {x: number; y: number}
function get_point_to_point_proximity(point_a, point_b)
local dx, dy = point_a.x - point_b.x, point_a.y - point_b.y
return math.sqrt(dx * dx + dy * dy)
end
-- Call function with args if it exists
function call_maybe(fn, ...)
if type(fn) == 'function' then fn(...) end
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
---@param max_seconds number|nil Trims unnecessary `00:` if time is not expected to reach it.
---@return string
function format_time(seconds, max_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
if max_seconds then
local trim_length = (max_seconds < 60 and 7 or (max_seconds < 3600 and 4 or 0))
if trim_length > 0 then
local has_minus = seconds < 0
human = string.sub(human, trim_length + (has_minus and 1 or 0))
if has_minus then human = '-' .. human end
end
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.platform == '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.platform == '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.platform == '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.platform == '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.platform == '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.
-- Returned table will always contain `file_path`, regardless of `allowed_types`.
---@param file_path string
---@param allowed_types? string[] Filter adjacent file types. Does NOT filter out the `file_path`.
function get_adjacent_files(file_path, allowed_types)
local current_meta = serialize_path(file_path)
if not current_meta then return end
local files = read_directory(current_meta.dirname)
if not files then return end
sort_filenames(files)
local current_file_index
local paths = {}
for _, file in ipairs(files) do
local is_current_file = current_meta.basename == file
if is_current_file or not allowed_types or has_any_extension(file, allowed_types) then
paths[#paths + 1] = join_path(current_meta.dirname, file)
if is_current_file then current_file_index = #paths end
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.types.autoload)
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)
if state.platform == 'windows' then
if options.use_trash then
local ps_code = [[
Add-Type -AssemblyName Microsoft.VisualBasic
[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('__path__', 'OnlyErrorDialogs', 'SendToRecycleBin')
]]
local escaped_path = string.gsub(path, "'", "''")
escaped_path = string.gsub(escaped_path, "", "")
escaped_path = string.gsub(escaped_path, "%%", "%%%%")
ps_code = string.gsub(ps_code, "__path__", escaped_path)
args = { 'powershell', '-NoProfile', '-Command', ps_code }
else
args = { 'cmd', '/C', 'del', path }
end
else
if options.use_trash then
--On Linux and Macos the app trash-cli/trash must be installed first.
args = { 'trash', path }
else
args = { 'rm', path }
end
end
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$', ' opening$'
}, requires_next_chapter = true},
{name = 'intros', patterns = {
'^intro$', ' intro$',
'^avant$', '^prologue$'
}, requires_next_chapter = true},
{name = 'endings', patterns = {
'^ed ', '^ed$', ' ed$',
'^ending ', '^ending$', ' ending$',
}},
{name = 'outros', patterns = {
'^outro$', ' outro$',
'^closing$', '^closing ',
'^preview$', '^pv$',
}},
}
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()
cursor.reset_handlers()
-- 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
cursor.decide_keybinds()
-- 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