Files
uosc/scripts/uosc/lib/utils.lua
tomasklaen c00e296953 refactor: modularize the codebase
Splits the monolith file into individual modules with as little code refactoring as possible.

Not too happy with it, mainly because of the weird language server behavior where it doesn't recognize types or variables unless the files defining them are open...

It'd also be better to refactor everything from the ground up to not depend on global variables but if I had that much time to invest I'd straight up just rewrite everything in typescript and use TS→Lua transpiler.
2022-10-25 12:14:50 +02:00

482 lines
15 KiB
Lua

--[[ 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
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
-- 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
---@param a string|number
---@param b string|number
---@return boolean
return function(a, b)
a, b = tostring(a), tostring(b)
local ai = a:sub(1, 1)
local bi = b:sub(1, 1)
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_order < b_order end
return a:lower():gsub("%.?%d+", pad_number)..("%3d"):format(#b)
< b:lower():gsub("%.?%d+", pad_number)..("%3d"):format(#a)
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
-- 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 or 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
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_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$', '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()
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