mpv: Update gui interface
This commit is contained in:
parent
8a0fd53647
commit
01ca98aa4b
29 changed files with 6886 additions and 3569 deletions
170
multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua
Normal file
170
multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua
Normal file
|
|
@ -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
|
||||
292
multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua
Normal file
292
multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
---@param data MenuData
|
||||
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
|
||||
function open_command_menu(data, opts)
|
||||
local function run_command(command)
|
||||
if type(command) == 'string' then
|
||||
mp.command(command)
|
||||
else
|
||||
---@diagnostic disable-next-line: deprecated
|
||||
mp.commandv(unpack(command))
|
||||
end
|
||||
end
|
||||
---@type MenuOptions
|
||||
local menu_opts = {}
|
||||
if opts then
|
||||
menu_opts.mouse_nav = opts.mouse_nav
|
||||
if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end
|
||||
end
|
||||
local menu = Menu:open(data, run_command, menu_opts)
|
||||
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
|
||||
return menu
|
||||
end
|
||||
|
||||
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
|
||||
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); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
||||
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,
|
||||
on_move_item = options.on_move_item,
|
||||
on_delete_item = options.on_delete_item,
|
||||
})
|
||||
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.platform == '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
|
||||
|
||||
---@type MenuCallback
|
||||
local function open_path(path, meta)
|
||||
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 and not meta.modifiers.ctrl 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, {modifiers = {}}) 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
|
||||
181
multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua
Normal file
181
multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua
Normal file
|
|
@ -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
|
||||
461
multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua
Normal file
461
multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
-- 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
|
||||
}
|
||||
|
||||
local width_length_ratio = 0.5
|
||||
|
||||
---@type integer, integer
|
||||
local osd_width, osd_height = 100, 100
|
||||
|
||||
---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
|
||||
|
||||
mp.observe_property('osd-dimensions', 'native', function (_, dim)
|
||||
if dim then update_osd_resolution(dim.w, dim.h) end
|
||||
end)
|
||||
|
||||
local measure_bounds
|
||||
do
|
||||
local text_osd = mp.create_osd_overlay("ass-events")
|
||||
text_osd.compute_bounds, text_osd.hidden = true, true
|
||||
|
||||
---@param ass_text string
|
||||
---@return integer, integer, integer, integer
|
||||
measure_bounds = function(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
|
||||
end
|
||||
|
||||
local normalized_text_width
|
||||
do
|
||||
---@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
|
||||
normalized_text_width = function(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
|
||||
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
|
||||
|
||||
---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
|
||||
|
||||
local character_width
|
||||
do
|
||||
---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
|
||||
local char_width_cache = {}
|
||||
|
||||
---Get measured width of character
|
||||
---@param char string
|
||||
---@param bold boolean
|
||||
---@return number, integer
|
||||
character_width = function(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
|
||||
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
|
||||
|
||||
---Scale normalized width to real width based on font size and italic
|
||||
---@param opts {size: number; italic?: boolean}
|
||||
---@return number, number
|
||||
local function opts_factor_offset(opts)
|
||||
return opts.size, opts.italic and opts.size * 0.2 or 0
|
||||
end
|
||||
|
||||
---Scale normalized width to real width based on font size and italic
|
||||
---@param opts {size: number; italic?: boolean}
|
||||
---@return number
|
||||
local function normalized_to_real(width, opts)
|
||||
local factor, offset = opts_factor_offset(opts)
|
||||
return factor * width + offset
|
||||
end
|
||||
|
||||
do
|
||||
---@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 normalized_to_real(width_px[1], opts) end
|
||||
|
||||
local width, px = character_based_width(text, bold)
|
||||
width_cache[bold][text] = {width, px}
|
||||
return normalized_to_real(width, 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
|
||||
end
|
||||
|
||||
do
|
||||
---@type {[string]: string}
|
||||
local cache = {}
|
||||
|
||||
---Get width of formatted timestamp as if all the digits were replaced with 0
|
||||
---@param timestamp string
|
||||
---@param opts {size: number; bold?: boolean; italic?: boolean}
|
||||
---@return number
|
||||
function timestamp_width(timestamp, opts)
|
||||
local substitute = cache[#timestamp]
|
||||
if not substitute then
|
||||
substitute = timestamp:gsub('%d', '0')
|
||||
cache[#timestamp] = substitute
|
||||
end
|
||||
return text_width(substitute, opts)
|
||||
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, scale_offset = opts.bold or false, opts_factor_offset(opts)
|
||||
local wrap_at_chars = {' ', ' ', '-', '–'}
|
||||
local remove_when_wrap = {' ', ' '}
|
||||
local lines = {}
|
||||
for text_line in text:gmatch("([^\n]*)\n?") do
|
||||
local line_width = scale_offset
|
||||
local line_start = 1
|
||||
local before_end = nil
|
||||
local before_width = scale_offset
|
||||
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 + scale_offset
|
||||
else
|
||||
lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
|
||||
line_start = char_end + 1
|
||||
line_width = scale_offset
|
||||
end
|
||||
before_end = line_start
|
||||
before_width = scale_offset
|
||||
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
|
||||
609
multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua
Normal file
609
multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
--[[ 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue