summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts/uosc_shared/lib
diff options
context:
space:
mode:
authorJoe <rrbo@proton.me>2023-01-31 14:03:59 +0100
committerJoe <rrbo@proton.me>2023-01-31 14:03:59 +0100
commitefa2957045fe9ea4421ea4a0c546f62a8a27fb58 (patch)
tree816b267a9d431e28744db41429358516ab0c461b /.config/mpv/scripts/uosc_shared/lib
parentup (diff)
downloaddotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.gz
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.bz2
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.xz
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.zst
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.zip
update
Diffstat (limited to '.config/mpv/scripts/uosc_shared/lib')
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/ass.lua170
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/menus.lua282
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/std.lua181
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/text.lua421
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/utils.lua545
5 files changed, 1599 insertions, 0 deletions
diff --git a/.config/mpv/scripts/uosc_shared/lib/ass.lua b/.config/mpv/scripts/uosc_shared/lib/ass.lua
new file mode 100644
index 0000000..108953f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/ass.lua
@@ -0,0 +1,170 @@
+--[[ 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.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false
+ 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; rotate?: number; 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 .. ')\\rDefault\\an' .. align .. '\\blur0'
+ -- font
+ tags = tags .. '\\fn' .. (opts.font or config.font)
+ -- font size
+ tags = tags .. '\\fs' .. opts.size
+ -- bold
+ if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end
+ -- italic
+ if opts.italic then tags = tags .. '\\i1' end
+ -- rotate
+ if opts.rotate then tags = tags .. '\\frz' .. opts.rotate 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 bgt)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) 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; width_overwrite?: 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 = bg
+ 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 margin = (opts.width_overwrite or text_width(value, opts)) / 2 + 10
+ 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; border_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)\\rDefault\\an7\\blur0'
+ -- border
+ tags = tags .. '\\bord' .. border_size
+ -- colors
+ tags = tags .. '\\1c&H' .. (opts.color or fg)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ -- opacity
+ if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
+ if opts.border_opacity then tags = tags .. string.format('\\3a&H%X&', opacity_to_alpha(opts.border_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, bold = false, opacity = opacity, clip = clip})
+end
+
+-- Rotating spinner icon.
+---@param x number
+---@param y number
+---@param size number
+---@param opts? {color?: string; opacity?: number; clip?: string; border?: number; border_color?: string;}
+function ass_mt:spinner(x, y, size, opts)
+ opts = opts or {}
+ opts.rotate = (state.render_last_time * 1.75 % 1) * -360
+ opts.color = opts.color or fg
+ self:icon(x, y, size, 'autorenew', opts)
+ request_render()
+end
diff --git a/.config/mpv/scripts/uosc_shared/lib/menus.lua b/.config/mpv/scripts/uosc_shared/lib/menus.lua
new file mode 100644
index 0000000..e2a4ccc
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/menus.lua
@@ -0,0 +1,282 @@
+---@param data MenuData
+---@param opts? {submenu?: string; mouse_nav?: 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(value))
+ end
+ end, opts)
+ if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
+ return menu
+end
+
+---@param opts? {submenu?: string; mouse_nav?: 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; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; 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 list = mp.get_property_native(options.list_prop)
+ local active = options.active_prop and mp.get_property_native(options.active_prop) or nil
+ local menu
+
+ local function update() menu:update_items(options.serializer(list, active)) end
+
+ local ignore_initial_list = true
+ local function handle_list_prop_change(name, value)
+ if ignore_initial_list then ignore_initial_list = false
+ else list = value update() 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 active = value update() end
+ end
+
+ local initial_items, selected_index = options.serializer(list, active)
+
+ -- 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 = {}
+ local function h(value) hint_values[#hint_values + 1] = value end
+
+ if track.lang then h(track.lang:upper()) end
+ if track['demux-h'] then
+ h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
+ end
+ if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
+ h(track.codec)
+ if track['audio-channels'] then h(track['audio-channels'] .. ' channels') end
+ if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
+ if track.forced then h('forced') end
+ if track.default then h('default') end
+ if track.external then h('external') end
+
+ items[#items + 1] = {
+ title = (track.title and track.title or 'Track ' .. track.id),
+ hint = table.concat(hint_values, ', '),
+ 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',
+ 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(normalize_path(directory_path))
+ opts = opts or {}
+
+ if not directory then
+ msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
+ return
+ end
+
+ local files, directories = read_directory(directory.path, opts.allowed_types)
+ local is_root = not directory.dirname
+ local path_separator = path_separator(directory.path)
+
+ if not files or not directories then return end
+
+ sort_filenames(directories)
+ sort_filenames(files)
+
+ -- 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 = '{drives}', separator = true}
+ end
+ else
+ items[#items + 1] = {title = '..', hint = 'parent dir', value = directory.dirname, separator = true}
+ end
+
+ local back_path = items[#items] and items[#items].value
+ local selected_index = #items + 1
+
+ for _, dir in ipairs(directories) do
+ items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
+ end
+
+ for _, file in ipairs(files) do
+ items[#items + 1] = {title = file, value = join_path(directory.path, file)}
+ end
+
+ for index, item in ipairs(items) do
+ if not item.value.is_to_parent and opts.active_path == item.value then
+ item.active = true
+ if not opts.selected_path then selected_index = index end
+ end
+
+ if opts.selected_path == item.value then selected_index = index end
+ end
+
+ local function open_path(path)
+ local is_drives = path == '{drives}'
+ local is_to_parent = is_drives or #path < #directory_path
+ local inheritable_options = {
+ type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
+ }
+
+ if 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,
+ on_open = opts.on_open, on_close = opts.on_close,
+ })
+ return
+ end
+
+ local info, error = utils.file_info(path)
+
+ if not info then
+ msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
+ return
+ end
+
+ if info.is_dir then
+ -- Preselect directory we are coming from
+ if is_to_parent then
+ inheritable_options.selected_path = directory.path
+ end
+
+ open_file_navigation_menu(path, handle_select, inheritable_options)
+ else
+ handle_select(path)
+ end
+ end
+
+ local function handle_back()
+ if back_path then open_path(back_path) end
+ end
+
+ local menu_data = {
+ type = opts.type, title = opts.title or directory.basename .. path_separator, items = items,
+ selected_index = selected_index,
+ }
+ local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
+
+ return Menu:open(menu_data, open_path, menu_options)
+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, selected_index = {}, 1
+
+ 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, active = opts.active_path == drive_path,
+ }
+ if opts.selected_path == drive_path then selected_index = #items end
+ end
+ end
+ else
+ msg.error(process.stderr)
+ end
+
+ return Menu:open(
+ {type = opts.type, title = opts.title or 'Drives', items = items, selected_index = selected_index},
+ handle_select
+ )
+end
diff --git a/.config/mpv/scripts/uosc_shared/lib/std.lua b/.config/mpv/scripts/uosc_shared/lib/std.lua
new file mode 100644
index 0000000..1261666
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/std.lua
@@ -0,0 +1,181 @@
+--[[ Stateless utilities missing in lua standard library ]]
+
+---@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
+
+-- Trim any `char` from the end of the string.
+---@param str string
+---@param char string
+---@return string
+function trim_end(str, char)
+ local char, end_i = char:byte(), 0
+ for i = #str, 1, -1 do
+ if str:byte(i) ~= char then
+ end_i = i
+ break
+ end
+ end
+ return str:sub(1, end_i)
+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
+
+-- Get index of the last appearance of `sub` in `str`.
+---@param str string
+---@param sub string
+---@return integer|nil
+function string_last_index_of(str, sub)
+ local sub_length = #sub
+ for i = #str, 1, -1 do
+ for j = 1, sub_length do
+ if str:byte(i + j - 1) ~= sub:byte(j) then break end
+ if j == sub_length then return i end
+ end
+ end
+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
+
+--[[ EASING FUNCTIONS ]]
+
+function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
+function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end
+
+--[[ CLASSES ]]
+
+---@class Class
+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
diff --git a/.config/mpv/scripts/uosc_shared/lib/text.lua b/.config/mpv/scripts/uosc_shared/lib/text.lua
new file mode 100644
index 0000000..7bb456f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/text.lua
@@ -0,0 +1,421 @@
+-- https://en.wikipedia.org/wiki/Unicode_block
+---@alias CodePointRange {[1]: integer; [2]: integer}
+
+---@type CodePointRange[]
+local zero_width_blocks = {
+ {0x0000, 0x001F}, -- C0
+ {0x007F, 0x009F}, -- Delete + C1
+ {0x034F, 0x034F}, -- combining grapheme joiner
+ {0x061C, 0x061C}, -- Arabic Letter Strong
+ {0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
+ {0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
+ {0x2060, 0x2060}, -- word joiner
+ {0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
+ {0xFEFF, 0xFEFF}, -- zero-width non-breaking space
+ -- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
+ {0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
+ {0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
+ {0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
+ {0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
+ {0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
+ -- Egyptian Hieroglyph Format Controls and Shorthand format Controls
+ {0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
+ {0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
+ -- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
+ {0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
+}
+
+-- All characters have the same width as the first one
+---@type CodePointRange[]
+local same_width_blocks = {
+ {0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han
+ {0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han
+ {0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han
+ {0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han
+ {0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han
+ {0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han
+ {0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han
+ {0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han
+ {0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han
+ {0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han
+}
+
+---Get byte count of utf-8 character at index i in str
+---@param str string
+---@param i integer?
+---@return integer
+local function utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ if char_byte < 0xC0 then return 1
+ elseif char_byte < 0xE0 then return 2
+ elseif char_byte < 0xF0 then return 3
+ elseif char_byte < 0xF8 then return 4
+ else return 1 end
+end
+
+---Creates an iterator for an utf-8 encoded string
+---Iterates over utf-8 characters instead of bytes
+---@param str string
+---@return fun(): integer?, string?
+local function utf8_iter(str)
+ local byte_start = 1
+ return function()
+ local start = byte_start
+ if #str < start then return nil end
+ local byte_count = utf8_char_bytes(str, start)
+ byte_start = start + byte_count
+ return start, str:sub(start, start + byte_count - 1)
+ end
+end
+
+---Extract Unicode code point from utf-8 character at index i in str
+---@param str string
+---@param i integer
+---@return integer
+local function utf8_to_unicode(str, i)
+ local byte_count = utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local unicode = char_byte
+ if byte_count ~= 1 then
+ local shift = 2 ^ (8 - byte_count)
+ char_byte = char_byte - math.floor(0xFF / shift) * shift
+ unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
+ end
+ for j = 2, byte_count do
+ char_byte = str:byte(i + j - 1) - 0x80
+ unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
+ end
+ return round(unicode)
+end
+
+---Convert Unicode code point to utf-8 string
+---@param unicode integer
+---@return string?
+local function unicode_to_utf8(unicode)
+ if unicode < 0x80 then return string.char(unicode)
+ else
+ local byte_count
+ if unicode < 0x800 then byte_count = 2
+ elseif unicode < 0x10000 then byte_count = 3
+ elseif unicode < 0x110000 then byte_count = 4
+ else return end -- too big
+
+ local res = {}
+ local shift = 2 ^ 6
+ local after_shift = unicode
+ for _ = byte_count, 2, -1 do
+ local before_shift = after_shift
+ after_shift = math.floor(before_shift / shift)
+ table.insert(res, 1, before_shift - after_shift * shift + 0x80)
+ end
+ shift = 2 ^ (8 - byte_count)
+ table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift)
+ ---@diagnostic disable-next-line: deprecated
+ return string.char(unpack(res))
+ end
+end
+
+---Update osd resolution if valid
+---@param width integer
+---@param height integer
+local function update_osd_resolution(width, height)
+ if width > 0 and height > 0 then osd_width, osd_height = width, height end
+end
+
+local text_osd = mp.create_osd_overlay("ass-events")
+text_osd.compute_bounds, text_osd.hidden = true, true
+---@type integer, integer
+local osd_width, osd_height = 100, 100
+mp.observe_property('osd-dimensions', 'native', function (_, dim)
+ if dim then update_osd_resolution(dim.w, dim.h) end
+end)
+
+---@param ass_text string
+---@return integer, integer, integer, integer
+local function measure_bounds(ass_text)
+ update_osd_resolution(mp.get_osd_size())
+ text_osd.res_x, text_osd.res_y = osd_width, osd_height
+ text_osd.data = ass_text
+ local res = text_osd:update()
+ return res.x0, res.y0, res.x1, res.y1
+end
+
+---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number}
+local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0}
+
+---Measure text width and normalize to a font size of 1
+---text has to be ass safe
+---@param text string
+---@param size number
+---@param bold boolean
+---@param italic boolean
+---@param horizontal boolean
+---@return number, integer
+local function normalized_text_width(text, size, bold, italic, horizontal)
+ bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90
+ local x1, y1 = nil, nil
+ size = size / 0.8
+ -- prevent endless loop
+ local repetitions_left = 5
+ repeat
+ size = size * 0.8
+ bounds_opts.size = size
+ local ass = assdraw.ass_new()
+ ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts)
+ _, _, x1, y1 = measure_bounds(ass.text)
+ repetitions_left = repetitions_left - 1
+ -- make sure nothing got clipped
+ until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0
+ local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1)
+ return width / size, horizontal and osd_width or osd_height
+end
+
+---Estimates character length based on utf8 byte count
+---1 character length is roughly the size of a latin character
+---@param char string
+---@return number
+local function char_length(char)
+ return #char > 2 and 2 or 1
+end
+
+---Estimates string length based on utf8 byte count
+---Note: Making a string in the iterator with the character is a waste here,
+---but as this function is only used when measuring whole string widths it's fine
+---@param text string
+---@return number
+local function text_length(text)
+ if not text or text == '' then return 0 end
+ local text_length = 0
+ for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end
+ return text_length
+end
+
+local width_length_ratio = 0.5
+---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
+local char_width_cache = {}
+
+---Finds the best orientation of text on screen and returns the estimated max size
+---and if the text should be drawn horizontally
+---@param text string
+---@return number, boolean
+local function fit_on_screen(text)
+ local estimated_width = text_length(text) * width_length_ratio
+ if osd_width >= osd_height then
+ -- Fill the screen as much as we can, bigger is more accurate.
+ return math.min(osd_width / estimated_width, osd_height), true
+ else
+ return math.min(osd_height / estimated_width, osd_width), false
+ end
+end
+
+---Gets next stage from cache
+---@param cache {[any]: table}
+---@param value any
+local function get_cache_stage(cache, value)
+ local stage = cache[value]
+ if not stage then
+ stage = {}
+ cache[value] = stage
+ end
+ return stage
+end
+
+---Is measured resolution sufficient
+---@param px integer
+---@return boolean
+local function no_remeasure_required(px)
+ return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height)
+end
+
+---Get measured width of character
+---@param char string
+---@param bold boolean
+---@return number, integer
+local function character_width(char, bold)
+ ---@type {[string]: {[1]: number, [2]: integer}}
+ local char_widths = get_cache_stage(char_width_cache, bold)
+ local width_px = char_widths[char]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end
+
+ local unicode = utf8_to_unicode(char, 1)
+ for _, block in ipairs(zero_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ char_widths[char] = {0, infinity}
+ return 0, infinity
+ end
+ end
+
+ local measured_char = nil
+ for _, block in ipairs(same_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ measured_char = unicode_to_utf8(block[1])
+ width_px = char_widths[measured_char]
+ if width_px and no_remeasure_required(width_px[2]) then
+ char_widths[char] = width_px
+ return width_px[1], width_px[2]
+ end
+ break
+ end
+ end
+
+ if not measured_char then measured_char = char end
+ -- half as many repetitions for wide characters
+ local char_count = 10 / char_length(char)
+ local max_size, horizontal = fit_on_screen(measured_char:rep(char_count))
+ local size = math.min(max_size * 0.9, 50)
+ char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100)
+ local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count
+ if measured_char == enclosing_char then enclosing_char = ''
+ else enclosing_width = 2 * character_width(enclosing_char, bold) end
+ local width_ratio, width, px = nil, nil, nil
+ repeat
+ char_count = next_char_count
+ local str = enclosing_char .. measured_char:rep(char_count) .. enclosing_char
+ width, px = normalized_text_width(str, size, bold, false, horizontal)
+ width = width - enclosing_width
+ width_ratio = width * size / (horizontal and osd_width or osd_height)
+ next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100)
+ until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count
+ width = width / char_count
+
+ width_px = {width, px}
+ if char ~= measured_char then char_widths[measured_char] = width_px end
+ char_widths[char] = width_px
+ return width, px
+end
+
+---Calculate text width from individual measured characters
+---@param text string|number
+---@param bold boolean
+---@return number, integer
+local function character_based_width(text, bold)
+ local max_width = 0
+ local min_px = infinity
+ for line in tostring(text):gmatch("([^\n]*)\n?") do
+ local total_width = 0
+ for _, char in utf8_iter(line) do
+ local width, px = character_width(char, bold)
+ total_width = total_width + width
+ if px < min_px then min_px = px end
+ end
+ if total_width > max_width then max_width = total_width end
+ end
+ return max_width, min_px
+end
+
+---Measure width of whole text
+---@param text string|number
+---@param bold boolean
+---@param italic boolean
+---@return number, integer
+local function whole_text_width(text, bold, italic)
+ text = tostring(text)
+ local size, horizontal = fit_on_screen(text)
+ return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal)
+end
+
+---Get scale factor calculated from font size, bold and italic
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+local function opts_scale_factor(opts)
+ return (opts.italic and 1.01 or 1) * opts.size
+end
+
+---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}
+local width_cache = {}
+
+---Calculate width of text with the given opts
+---@param text string|number
+---@return number
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+function text_width(text, opts)
+ if not text or text == '' then return 0 end
+
+ ---@type boolean, boolean
+ local bold, italic = opts.bold or options.font_bold, opts.italic or false
+
+ if options.text_width_estimation then
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(width_cache, bold)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts_scale_factor(opts) end
+
+ local width, px = character_based_width(text, bold)
+ width_cache[bold][text] = {width, px}
+ return width * opts_scale_factor(opts)
+ else
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end
+
+ local width, px = whole_text_width(text, bold, italic)
+ width_cache[bold][italic][text] = {width, px}
+ return width * opts.size
+ end
+end
+
+---Wrap the text at the closest opportunity to target_line_length
+---@param text string
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+---@param target_line_length number
+---@return string
+function wrap_text(text, opts, target_line_length)
+ local target_line_width = target_line_length * width_length_ratio * opts.size
+ local bold, scale_factor = opts.bold or false, opts_scale_factor(opts)
+ local wrap_at_chars = {' ', ' ', '-', '–'}
+ local remove_when_wrap = {' ', ' '}
+ local lines = {}
+ for text_line in text:gmatch("([^\n]*)\n?") do
+ local line_width = 0
+ local line_start = 1
+ local before_end = nil
+ local before_width = 0
+ local before_line_start = 0
+ local before_removed_width = 0
+ for char_start, char in utf8_iter(text_line) do
+ local char_end = char_start + #char - 1
+ local can_wrap = false
+ for _, c in ipairs(wrap_at_chars) do
+ if char == c then
+ can_wrap = true
+ break
+ end
+ end
+ local char_width = character_width(char, bold) * scale_factor
+ line_width = line_width + char_width
+ if can_wrap or (char_end == #text_line) then
+ local remove = false
+ for _, c in ipairs(remove_when_wrap) do
+ if char == c then
+ remove = true
+ break
+ end
+ end
+ local line_width_after_remove = line_width - (remove and char_width or 0)
+ if line_width_after_remove < target_line_width then
+ before_end = remove and char_start - 1 or char_end
+ before_width = line_width_after_remove
+ before_line_start = char_end + 1
+ before_removed_width = remove and char_width or 0
+ else
+ if (target_line_width - before_width) <
+ (line_width_after_remove - target_line_width) then
+ lines[#lines + 1] = text_line:sub(line_start, before_end)
+ line_start = before_line_start
+ line_width = line_width - before_width - before_removed_width
+ else
+ lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
+ line_start = char_end + 1
+ line_width = remove and line_width - char_width or line_width
+ line_width = 0
+ end
+ before_end = line_start
+ before_width = 0
+ end
+ end
+ end
+ if #text_line >= line_start then lines[#lines + 1] = text_line:sub(line_start)
+ elseif text_line == '' then lines[#lines + 1] = '' end
+ end
+ return table.concat(lines, '\n')
+end
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