Add mpv config

This commit is contained in:
BuyMyMojo 2025-03-24 04:38:41 +11:00
parent 5e0be74606
commit 709a064053
Signed by untrusted user who does not match committer: aria
GPG key ID: 19AB7AA462B8AB3B
106 changed files with 42838 additions and 0 deletions

242
mpv/scripts/uosc/lib/ass.lua Executable file
View file

@ -0,0 +1,242 @@
--[[ ASSDRAW EXTENSIONS ]]
local ass_mt = getmetatable(assdraw.ass_new())
-- Opacity.
---@param self table|nil
---@param opacity number|{primary?: number; border?: number, shadow?: number, main?: number} Opacity of all elements.
---@param fraction? number Optionally adjust the above opacity by this fraction.
---@return string|nil
function ass_mt.opacity(self, opacity, fraction)
fraction = fraction ~= nil and fraction or 1
opacity = type(opacity) == 'table' and opacity or {main = opacity}
local text = ''
if opacity.main then
text = text .. string.format('\\alpha&H%X&', opacity_to_alpha(opacity.main * fraction))
end
if opacity.primary then
text = text .. string.format('\\1a&H%X&', opacity_to_alpha(opacity.primary * fraction))
end
if opacity.border then
text = text .. string.format('\\3a&H%X&', opacity_to_alpha(opacity.border * fraction))
end
if opacity.shadow then
text = text .. string.format('\\4a&H%X&', opacity_to_alpha(opacity.shadow * fraction))
end
if self == nil then
return text
elseif text ~= '' then
self.text = self.text .. '{' .. text .. '}'
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|{primary?: number; border?: number, shadow?: number, main?: 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 .. self.opacity(nil, 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 Rect
---@param value string|number
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean}
function ass_mt:tooltip(element, value, opts)
if value == '' then return end
opts = opts or {}
opts.size = opts.size or round(16 * state.scale)
opts.border = options.text_border * state.scale
opts.border_color = bg
opts.margin = opts.margin or round(10 * state.scale)
opts.lines = opts.lines or 1
local padding_y = round(opts.size / 6)
local padding_x = round(opts.size / 3)
local offset = opts.offset or 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 width_half = (opts.width_overwrite or text_width(value, opts)) / 2 + padding_x
local min_edge_distance = width_half + opts.margin + Elements:v('window_border', 'size', 0)
x = clamp(min_edge_distance, x, display.width - min_edge_distance)
local ax, bx = round(x - width_half), round(x + width_half)
local ay = (align_top and y - opts.size * opts.lines - 2 * padding_y or y)
local by = (align_top and y or y + opts.size * opts.lines + 2 * padding_y)
self:rect(ax, ay, bx, by, {color = bg, opacity = config.opacity.tooltip, radius = state.radius})
local func = opts.timestamp and self.timestamp or self.txt
func(self, x, align_top and y - padding_y or y + padding_y, align_top and 2 or 8, tostring(value), opts)
return {ax = element.ax, ay = ay, bx = element.bx, by = by}
end
-- Timestamp with each digit positioned as if it was replaced with 0
---@param x number
---@param y number
---@param align number
---@param timestamp string
---@param opts {size: number; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}}
function ass_mt:timestamp(x, y, align, timestamp, opts)
local widths, width_total = {}, 0
zero_rep = timestamp_zero_rep(timestamp)
for i = 1, #zero_rep do
local width = text_width(zero_rep:sub(i, i), opts)
widths[i] = width
width_total = width_total + width
end
-- shift x and y to fit align 5
local mod_align = align % 3
if mod_align == 0 then
x = x - width_total
elseif mod_align == 2 then
x = x - width_total / 2
end
if align < 4 then
y = y - opts.size / 2
elseif align > 6 then
y = y + opts.size / 2
end
local opacity = opts.opacity
local primary_opacity
if type(opacity) == 'table' then
opts.opacity = {main = opacity.main, border = opacity.border, shadow = opacity.shadow, primary = 0}
primary_opacity = opacity.primary or opacity.main
else
opts.opacity = {main = opacity, primary = 0}
primary_opacity = opacity
end
for i, width in ipairs(widths) do
self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
x = x + width
end
x = x - width_total
opts.opacity = {main = 0, primary = primary_opacity or 1}
for i, width in ipairs(widths) do
self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
x = x + width
end
opts.opacity = opacity
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|{primary?: number; border?: number, shadow?: number, main?: 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 .. self.opacity(nil, opts.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 and opts.radius > 0 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

View file

@ -0,0 +1,72 @@
require('lib/text')
local char_dir = mp.get_script_directory() .. '/char-conv/'
local data = {}
local languages = get_languages()
for i = #languages, 1, -1 do
lang = languages[i]
if (lang == 'en') then
data = {}
else
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
end
end
local romanization = {}
local function get_romanization_table()
for k, v in pairs(data) do
for _, char in utf8_iter(v) do
romanization[char] = k
end
end
end
get_romanization_table()
function need_romanization()
return next(romanization) ~= nil
end
function char_conv(chars, use_ligature, has_separator)
local separator = has_separator or ' '
local length = 0
local char_conv, sp, cache = {}, {}, {}
local chars_length = utf8_length(chars)
local concat = table.concat
for _, char in utf8_iter(chars) do
if use_ligature then
if #char == 1 then
char_conv[#char_conv + 1] = char
else
char_conv[#char_conv + 1] = romanization[char] or char
end
else
length = length + 1
if #char <= 2 then
if (char ~= ' ' and length ~= chars_length) then
cache[#cache + 1] = romanization[char] or char
elseif (char == ' ' or length == chars_length) then
if length == chars_length then
cache[#cache + 1] = romanization[char] or char
end
sp[#sp + 1] = concat(cache)
itable_clear(cache)
end
else
if next(cache) ~= nil then
sp[#sp + 1] = concat(cache)
itable_clear(cache)
end
sp[#sp + 1] = romanization[char] or char
end
end
end
if use_ligature then
return concat(char_conv)
else
return concat(sp, separator)
end
end
return char_conv

410
mpv/scripts/uosc/lib/cursor.lua Executable file
View file

@ -0,0 +1,410 @@
local cursor = {
x = math.huge,
y = math.huge,
hidden = true,
hover_raw = false,
-- Event handlers that are only fired on zones defined during render loop.
---@type {event: string, hitbox: Hitbox; handler: fun(...)}[]
zones = {},
handlers = {
primary_down = {},
primary_up = {},
secondary_down = {},
secondary_up = {},
wheel_down = {},
wheel_up = {},
move = {},
},
first_real_mouse_move_received = false,
history = CircularBuffer:new(10),
autohide_fs_only = nil,
-- Tracks current key binding levels for each event. 0: disabled, 1: enabled, 2: enabled + window dragging prevented
binding_levels = {
mbtn_left = 0,
mbtn_left_dbl = 0,
mbtn_right = 0,
wheel = 0,
},
is_dragging_prevented = false,
event_forward_map = {
primary_down = 'MBTN_LEFT',
primary_up = 'MBTN_LEFT',
secondary_down = 'MBTN_RIGHT',
secondary_up = 'MBTN_RIGHT',
wheel_down = 'WHEEL_DOWN',
wheel_up = 'WHEEL_UP',
},
event_binding_map = {
primary_down = 'mbtn_left',
primary_up = 'mbtn_left',
primary_click = 'mbtn_left',
secondary_down = 'mbtn_right',
secondary_up = 'mbtn_right',
secondary_click = 'mbtn_right',
wheel_down = 'wheel',
wheel_up = 'wheel',
},
window_dragging_blockers = create_set({'primary_click', 'primary_down'}),
event_propagation_blockers = {
primary_down = 'primary_click',
primary_click = 'primary_down',
secondary_down = 'secondary_click',
secondary_click = 'secondary_down',
},
event_parent_map = {
primary_down = {is_start = true, trigger_event = 'primary_click'},
primary_up = {is_end = true, start_event = 'primary_down', trigger_event = 'primary_click'},
secondary_down = {is_start = true, trigger_event = 'secondary_click'},
secondary_up = {is_end = true, start_event = 'secondary_down', trigger_event = 'secondary_click'},
},
-- Holds positions of last events.
---@type {[string]: {x: number, y: number, time: number}}
last_event = {},
}
cursor.autohide_timer = mp.add_timeout(1, function() cursor:autohide() end)
cursor.autohide_timer:kill()
mp.observe_property('cursor-autohide', 'number', function(_, val)
cursor.autohide_timer.timeout = (val or 1000) / 1000
end)
-- Called at the beginning of each render
function cursor:clear_zones()
itable_clear(self.zones)
end
---@param hitbox Hitbox
function cursor:collides_with(hitbox)
return point_collides_with(self, hitbox)
end
-- Returns zone for event at current cursor position.
---@param event string
function cursor:find_zone(event)
-- Premature optimization to ignore a high frequency event that is not needed as a zone atm.
if event == 'move' then return end
for i = #self.zones, 1, -1 do
local zone = self.zones[i]
local is_blocking_only = zone.event == self.event_propagation_blockers[event]
if (zone.event == event or is_blocking_only) and self:collides_with(zone.hitbox) then
return not is_blocking_only and zone or nil
end
end
end
-- Defines an event zone for a hitbox on currently rendered screen. Available events:
-- - primary_down, primary_up, primary_click, secondary_down, secondary_up, secondary_click, wheel_down, wheel_up
--
-- Notes:
-- - Zones are cleared on beginning of every `render()`, and need to be rebound.
-- - One event type per zone: only the last bound zone per event gets triggered.
-- - In current implementation, you have to choose between `_click` or `_down`. Binding both makes only the last bound fire.
-- - Primary `_down` and `_click` disable dragging. Define `window_drag = true` on hitbox to re-enable.
-- - Anything that disables dragging also implicitly disables cursor autohide.
-- - `move` event zones are ignored due to it being a high frequency event that is currently not needed as a zone.
---@param event string
---@param hitbox Hitbox
---@param callback fun(...)
function cursor:zone(event, hitbox, callback)
self.zones[#self.zones + 1] = {event = event, hitbox = hitbox, handler = callback}
end
-- Binds a permanent cursor event handler active until manually unbound using `cursor:off()`.
-- `_click` events are not available as permanent global events, only as zones.
---@param event string
---@return fun() disposer Unbinds the event.
function cursor:on(event, callback)
if self.handlers[event] and not itable_index_of(self.handlers[event], callback) then
self.handlers[event][#self.handlers[event] + 1] = callback
self:decide_keybinds()
end
return function() self:off(event, callback) end
end
-- Unbinds a cursor event handler.
---@param event string
function cursor:off(event, callback)
if self.handlers[event] then
local index = itable_index_of(self.handlers[event], callback)
if index then
table.remove(self.handlers[event], index)
self:decide_keybinds()
end
end
end
-- Binds a cursor event handler to be called once.
---@param event string
function cursor:once(event, callback)
local function callback_wrap()
callback()
self:off(event, callback_wrap)
end
return self:on(event, callback_wrap)
end
-- Trigger the event.
---@param event string
function cursor:trigger(event, ...)
local forward = true
-- Call raw event handlers.
local zone = self:find_zone(event)
local callbacks = self.handlers[event]
if zone or #callbacks > 0 then
forward = false
if zone then zone.handler(...) end
for _, callback in ipairs(callbacks) do callback(...) end
end
-- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
local parent = self.event_parent_map[event]
if parent then
local parent_zone = self:find_zone(parent.trigger_event)
if parent_zone then
forward = false -- Canceled here so we don't forward down events if they can lead to a click.
if parent.is_end then
local last_start_event = self.last_event[parent.start_event]
if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) then
parent_zone.handler(...)
end
end
end
end
-- Forward unhandled events.
if forward then
local forward_name = self.event_forward_map[event]
if forward_name then
-- Forward events if there was no handler.
local active = find_active_keybindings(forward_name)
if active then
local is_wheel = event:find('wheel', 1, true)
local is_up = event:sub(-3) == '_up'
if active.owner then
-- Binding belongs to other script, so make it look like regular key event.
-- Mouse bindings are simple, other keys would require repeat and pressed handling,
-- which can't be done with mp.set_key_bindings(), but is possible with mp.add_key_binding().
local state = is_wheel and 'pm' or is_up and 'um' or 'dm'
local name = active.cmd:sub(active.cmd:find('/') + 1, -1)
mp.commandv('script-message-to', active.owner, 'key-binding', name, state, forward_name)
elseif is_wheel or is_up then
-- input.conf binding, react to button release for mouse buttons
mp.command(active.cmd)
end
end
end
end
-- Update last event position.
local last = self.last_event[event] or {}
last.x, last.y, last.time = self.x, self.y, mp.get_time()
self.last_event[event] = last
-- Refresh cursor autohide timer.
self:queue_autohide()
end
-- Enables or disables keybinding groups based on what event listeners are bound.
function cursor:decide_keybinds()
local new_levels = {mbtn_left = 0, mbtn_right = 0, wheel = 0}
self.is_dragging_prevented = false
-- Check global events.
for name, handlers in ipairs(self.handlers) do
local binding = self.event_binding_map[name]
if binding then
new_levels[binding] = #handlers > 0 and 1 or 0
end
end
-- Check zones.
for _, zone in ipairs(self.zones) do
local binding = self.event_binding_map[zone.event]
if binding and cursor:collides_with(zone.hitbox) then
local new_level = (self.window_dragging_blockers[zone.event] and zone.hitbox.window_drag ~= true) and 2
or math.max(new_levels[binding], zone.hitbox.window_drag == false and 2 or 1)
new_levels[binding] = new_level
if new_level > 1 then
self.is_dragging_prevented = true
end
end
end
-- Window dragging only gets prevented when on top of an element, which is when double clicks should be ignored.
new_levels.mbtn_left_dbl = new_levels.mbtn_left == 2 and 2 or 0
for name, level in pairs(new_levels) do
if level ~= self.binding_levels[name] then
local flags = level == 1 and 'allow-vo-dragging+allow-hide-cursor' or ''
mp[(level == 0 and 'disable' or 'enable') .. '_key_bindings'](name, flags)
self.binding_levels[name] = level
self:queue_autohide()
end
end
end
function cursor:_find_history_sample()
local time = mp.get_time()
for _, e in self.history:iter_rev() do
if time - e.time > 0.1 then
return e
end
end
return self.history:tail()
end
-- Returns a table with current velocities in in pixels per second.
---@return Point
function cursor:get_velocity()
local snap = self:_find_history_sample()
if snap then
local x, y, time = self.x - snap.x, self.y - snap.y, mp.get_time()
local time_diff = time - snap.time
if time_diff > 0.001 then
return {x = x / time_diff, y = y / time_diff}
end
end
return {x = 0, y = 0}
end
---@param x integer
---@param y integer
function cursor:move(x, y)
local old_x, old_y = self.x, self.y
-- mpv reports initial mouse position on linux as (0, 0), which always
-- displays the top bar, so we hardcode cursor position as infinity until
-- we receive a first real mouse move event with coordinates other than 0,0.
if not self.first_real_mouse_move_received then
if x > 0 and y > 0 then
self.first_real_mouse_move_received = true
else
x, y = math.huge, math.huge
end
end
-- Add 0.5 to be in the middle of the pixel
self.x, self.y = x + 0.5, y + 0.5
if old_x ~= self.x or old_y ~= self.y then
if self.x == math.huge or self.y == math.huge then
self.hidden = true
self.history:clear()
-- Slowly fadeout elements that are currently visible
for _, id in ipairs(config.cursor_leave_fadeout_elements) do
local element = Elements[id]
if element then
local visibility = element:get_visibility()
if visibility > 0 then
element:tween_property('forced_visibility', visibility, 0, function()
element.forced_visibility = nil
end)
end
end
end
Elements:update_proximities()
Elements:trigger('global_mouse_leave')
else
if self.hidden then
-- Cancel potential fadeouts
for _, id in ipairs(config.cursor_leave_fadeout_elements) do
if Elements[id] then Elements[id]:tween_stop() end
end
self.hidden = false
Elements:trigger('global_mouse_enter')
end
Elements:update_proximities()
-- Update history
self.history:insert({x = self.x, y = self.y, time = mp.get_time()})
end
Elements:proximity_trigger('mouse_move')
self:queue_autohide()
end
self:trigger('move')
request_render()
end
function cursor:leave() self:move(math.huge, math.huge) end
function cursor:is_autohide_allowed()
return options.autohide and (not self.autohide_fs_only or state.fullscreen)
and not self.is_dragging_prevented
and not Menu:is_open()
end
mp.observe_property('cursor-autohide-fs-only', 'bool', function(_, val) cursor.autohide_fs_only = val end)
-- Cursor auto-hiding after period of inactivity.
function cursor:autohide()
if self:is_autohide_allowed() then
self:leave()
self.autohide_timer:kill()
end
end
function cursor:queue_autohide()
if self:is_autohide_allowed() then
self.autohide_timer:kill()
self.autohide_timer:resume()
end
end
-- Calculates distance in which cursor reaches rectangle if it continues moving on the same path.
-- Returns `nil` if cursor is not moving towards the rectangle.
---@param rect Rect
function cursor:direction_to_rectangle_distance(rect)
local prev = self:_find_history_sample()
if not prev then return false end
local end_x, end_y = self.x + (self.x - prev.x) * 1e10, self.y + (self.y - prev.y) * 1e10
return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
end
function cursor:create_handler(event, cb)
return function(...)
call_maybe(cb, ...)
self:trigger(event, ...)
end
end
-- Movement
function handle_mouse_pos(_, mouse)
if not mouse then return end
if cursor.hover_raw and not mouse.hover then
cursor:leave()
else
cursor:move(mouse.x, mouse.y)
end
cursor.hover_raw = mouse.hover
end
mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
-- Key binding groups
mp.set_key_bindings({
{
'mbtn_left',
cursor:create_handler('primary_up'),
cursor:create_handler('primary_down', function(...)
handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
end),
},
}, 'mbtn_left', 'force')
mp.set_key_bindings({
{'mbtn_left_dbl', 'ignore'},
}, 'mbtn_left_dbl', 'force')
mp.set_key_bindings({
{'mbtn_right', cursor:create_handler('secondary_up'), cursor:create_handler('secondary_down')},
}, 'mbtn_right', 'force')
mp.set_key_bindings({
{'wheel_up', cursor:create_handler('wheel_up')},
{'wheel_down', cursor:create_handler('wheel_down')},
}, 'wheel', 'force')
return cursor

68
mpv/scripts/uosc/lib/intl.lua Executable file
View file

@ -0,0 +1,68 @@
local intl_dir = mp.get_script_directory() .. '/intl/'
local locale = {}
local cache = {}
-- https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/supported-languages?pivots=store-installer-msix#list-of-supported-languages
function get_languages()
local languages = {}
for _, lang in ipairs(comma_split(options.languages)) do
if (lang == 'slang') then
local slang = mp.get_property_native('slang')
if slang then
itable_append(languages, slang)
end
else
languages[#languages +1] = lang
end
end
return languages
end
---@param path string
function get_locale_from_json(path)
local expand_path = mp.command_native({'expand-path', path})
local meta, meta_error = utils.file_info(expand_path)
if not meta or not meta.is_file then
return nil
end
local json_file = io.open(expand_path, 'r')
if not json_file then
return nil
end
local json = json_file:read('*all')
json_file:close()
local json_table = utils.parse_json(json)
return json_table
end
---@param text string
function t(text, a)
if not text then return '' end
local key = text
if a then key = key .. '|' .. a end
if cache[key] then return cache[key] end
cache[key] = string.format(locale[text] or text, a or '')
return cache[key]
end
-- Load locales
local languages = get_languages()
for i = #languages, 1, -1 do
lang = languages[i]
if (lang:match('.json$')) then
table_assign(locale, get_locale_from_json(lang))
elseif (lang == 'en') then
locale = {}
else
table_assign(locale, get_locale_from_json(intl_dir .. lang:lower() .. '.json'))
end
end
return {t = t}

831
mpv/scripts/uosc/lib/menus.lua Executable file
View file

@ -0,0 +1,831 @@
---@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 = get_menu_items(), search_submenus = true}, opts)
end
end
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); 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(opts)
return function()
if Menu:is_open(opts.type) then
Menu:close()
return
end
local list = mp.get_property_native(opts.list_prop)
local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
local menu
local function update() menu:update_items(opts.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 = opts.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 = opts.type,
title = opts.title,
items = initial_items,
selected_index = selected_index,
on_paste = opts.on_paste,
},
opts.on_select, {
on_open = function()
mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
if opts.active_prop then
mp.observe_property(opts.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 = opts.on_move_item,
on_delete_item = opts.on_delete_item,
})
end
end
function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command, download_command)
local function serialize_tracklist(tracklist)
local items = {}
if download_command then
items[#items + 1] = {
title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
}
end
if load_command then
items[#items + 1] = {
title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}',
}
end
if #items > 0 then
items[#items].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 = t('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'] == 1
and t('%s channel', track['audio-channels'])
or t('%s channels', track['audio-channels']))
end
if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
if track.forced then h(t('forced')) end
if track.default then h(t('default')) end
if track.external then h(t('external')) end
items[#items + 1] = {
title = (track.title and track.title or t('Track %s', 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 handle_select(value)
if value == '{download}' then
mp.command(download_command)
elseif value == '{load}' then
mp.command(load_command)
else
mp.commandv('set', track_prop, value and value or 'no')
-- If subtitle track was selected, assume the 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 = handle_select,
on_paste = function(path) load_track(track_type, path) end,
})
end
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, 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, mods: Modifiers): 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, {
types = opts.allowed_types,
hidden = options.show_hidden_files,
})
local is_root = not directory.dirname
local path_separator = path_separator(directory.path)
if not files or not directories then return end
sort_strings(directories)
sort_strings(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 = t('Drives'), value = '{drives}', separator = true}
end
else
items[#items + 1] = {title = '..', hint = t('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,
keep_open = opts.keep_open,
}
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.alt 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, meta.modifiers)
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,
keep_open = opts.keep_open,
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 = t('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 t('Drives'), items = items, selected_index = selected_index},
handle_select
)
end
-- On demand menu items loading
do
local items = nil
function get_menu_items()
if items then return items end
local input_conf_property = mp.get_property_native('input-conf')
local input_conf_iterator
if input_conf_property:sub(1, 9) == 'memory://' then
-- mpv.net v7
local input_conf_lines = split(input_conf_property:sub(10), '\n')
local i = 0
input_conf_iterator = function()
i = i + 1
return input_conf_lines[i]
end
else
local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property
local input_conf_path = mp.command_native({'expand-path', input_conf})
local input_conf_meta, meta_error = utils.file_info(input_conf_path)
-- File doesn't exist
if not input_conf_meta or not input_conf_meta.is_file then
items = create_default_menu_items()
return items
end
input_conf_iterator = io.lines(input_conf_path)
end
local main_menu = {items = {}, items_by_command = {}}
local by_id = {}
for line in input_conf_iterator do
local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
local title = ''
if comment then
local comments = split(comment, '#')
local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end)
if titles and #titles > 0 then
title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
end
end
if title ~= '' then
local is_dummy = key:sub(1, 1) == '#'
local submenu_id = ''
local target_menu = main_menu
local title_parts = split(title or '', ' *> *')
for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
if index < #title_parts then
submenu_id = submenu_id .. title_part
if not by_id[submenu_id] then
local items = {}
by_id[submenu_id] = {items = items, items_by_command = {}}
target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
end
target_menu = by_id[submenu_id]
else
if command == 'ignore' then break end
-- If command is already in menu, just append the key to it
if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
local hint = target_menu.items_by_command[command].hint
target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
else
-- Separator
if title_part:sub(1, 3) == '---' then
local last_item = target_menu.items[#target_menu.items]
if last_item then last_item.separator = true end
else
local item = {
title = title_part,
hint = not is_dummy and key or nil,
value = command,
}
if command == '' then
item.selectable = false
item.muted = true
item.italic = true
else
target_menu.items_by_command[command] = item
end
target_menu.items[#target_menu.items + 1] = item
end
end
end
end
end
end
items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
return items
end
end
-- Adapted from `stats.lua`
function get_keybinds_items()
local items = {}
local active = find_active_keybindings()
-- Convert to menu items
for _, bind in pairs(active) do
items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
end
-- Sort
table.sort(items, function(a, b) return a.title < b.title end)
return #items > 0 and items or {
{
title = t('%s are empty', '`input-bindings`'),
selectable = false,
align = 'center',
italic = true,
muted = true,
},
}
end
function open_stream_quality_menu()
if Menu:is_open('stream-quality') then
Menu:close()
return
end
local ytdl_format = mp.get_property_native('ytdl-format')
local items = {}
for _, height in ipairs(config.stream_quality_options) do
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
end
Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
mp.set_property('ytdl-format', format)
-- Reload the video to apply new format
-- This is taken from https://github.com/jgreco/mpv-youtube-quality
-- which is in turn taken from https://github.com/4e6/mpv-reload/
local duration = mp.get_property_native('duration')
local time_pos = mp.get_property('time-pos')
mp.command('playlist-play-index current')
-- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
-- duration property. When reloading VOD, to keep the current time position
-- we should provide offset from the start. Stream doesn't have fixed start.
-- Decent choice would be to reload stream from it's current 'live' position.
-- That's the reason we don't pass the offset when reloading streams.
if duration and duration > 0 then
local function seeker()
mp.commandv('seek', time_pos, 'absolute')
mp.unregister_event(seeker)
end
mp.register_event('file-loaded', seeker)
end
end)
end
function open_open_file_menu()
if Menu:is_open('open-file') then
Menu:close()
return
end
local directory
local active_file
if state.path == nil or is_protocol(state.path) then
local serialized = serialize_path(get_default_directory())
if serialized then
directory = serialized.path
active_file = nil
end
else
local serialized = serialize_path(state.path)
if serialized then
directory = serialized.dirname
active_file = serialized.path
end
end
if not directory then
msg.error('Couldn\'t serialize path "' .. state.path .. '".')
return
end
-- Update active file in directory navigation menu
local menu = nil
local function handle_file_loaded()
if menu and menu:is_alive() then
menu:activate_one_value(normalize_path(mp.get_property_native('path')))
end
end
menu = open_file_navigation_menu(
directory,
function(path, mods)
if mods.ctrl then
mp.commandv('loadfile', path, 'append')
else
mp.commandv('loadfile', path)
Menu:close()
end
end,
{
type = 'open-file',
allowed_types = config.types.media,
active_path = active_file,
keep_open = true,
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
on_close = function() mp.unregister_event(handle_file_loaded) end,
}
)
end
---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
function create_track_loader_menu_opener(opts)
local menu_type = 'load-' .. opts.name
local title = ({
subtitles = t('Load subtitles'),
audio = t('Load audio'),
video = t('Load video'),
})[opts.name]
return function()
if Menu:is_open(menu_type) then
Menu:close()
return
end
local path = state.path
if path then
if is_protocol(path) then
path = false
else
local serialized_path = serialize_path(path)
path = serialized_path ~= nil and serialized_path.dirname or false
end
end
if not path then
path = get_default_directory()
end
local function handle_select(path) load_track(opts.prop, path) end
open_file_navigation_menu(path, handle_select, {
type = menu_type, title = title, allowed_types = opts.allowed_types,
})
end
end
function open_subtitle_downloader()
local menu_type = 'download-subtitles'
---@type Menu
local menu
if Menu:is_open(menu_type) then
Menu:close()
return
end
local search_suggestion, file_path = '', nil
local destination_directory = mp.command_native({'expand-path', '~~/subtitles'})
local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
if state.path then
if is_protocol(state.path) then
if not is_protocol(state.title) then search_suggestion = state.title end
else
local serialized_path = serialize_path(state.path)
if serialized_path then
search_suggestion = serialized_path.filename
file_path = state.path
destination_directory = serialized_path.dirname
end
end
end
local handle_select, handle_search
-- Ensures response is valid, and returns its payload, or handles error reporting,
-- and returns `nil`, indicating the consumer should abort response handling.
local function ensure_response_data(success, result, error, check)
local data
if success and result and result.status == 0 then
data = utils.parse_json(result.stdout)
if not data or not check(data) then
data = (data and data.error == true) and data or {
error = true,
message = t('invalid response json (see console for details)'),
message_verbose = 'invalid response json: ' .. utils.to_string(result.stdout),
}
end
else
data = {
error = true,
message = error or t('process exited with code %s (see console for details)', result.status),
message_verbose = result.stdout .. result.stderr,
}
end
if data.error then
local message, message_verbose = data.message or t('unknown error'), data.message_verbose or data.message
if message_verbose then msg.error(message_verbose) end
menu:update_items({
{
title = message,
hint = t('error'),
muted = true,
italic = true,
selectable = false,
},
})
return
end
return data
end
---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number}
handle_select = function(data)
if data.kind == 'page' then
handle_search(data.query, data.page)
return
end
menu = Menu:open({
type = menu_type .. '-result',
search_style = 'disabled',
items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
}, function() end)
local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
'--file-id', tostring(data.id),
'--destination', destination_directory,
})
mp.command_native_async({
name = 'subprocess',
capture_stderr = true,
capture_stdout = true,
playback_only = false,
args = args,
}, function(success, result, error)
if not menu:is_alive() then return end
local data = ensure_response_data(success, result, error, function(data)
return type(data.file) == 'string'
end)
if not data then return end
load_track('sub', data.file)
menu:update_items({
{
title = t('Subtitles loaded & enabled'),
bold = true,
icon = 'check',
selectable = false,
},
{
title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
italic = true,
muted = true,
icon = 'file_download',
selectable = false,
},
{
title = t('Resets in: %s', data.reset_time),
italic = true,
muted = true,
icon = 'schedule',
selectable = false,
},
})
end)
end
---@param query string
---@param page number|nil
handle_search = function(query, page)
if not menu:is_alive() then return end
page = math.max(1, type(page) == 'number' and round(page) or 1)
menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
local args = itable_join({config.ziggy_path, 'search-subtitles'}, credentials)
local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
args[#args + 1] = '--languages'
args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
args[#args + 1] = '--page'
args[#args + 1] = tostring(page)
if file_path then
args[#args + 1] = '--hash'
args[#args + 1] = file_path
end
if query and #query > 0 then
args[#args + 1] = '--query'
args[#args + 1] = query
end
mp.command_native_async({
name = 'subprocess',
capture_stderr = true,
capture_stdout = true,
playback_only = false,
args = args,
}, function(success, result, error)
if not menu:is_alive() then return end
local data = ensure_response_data(success, result, error, function(data)
return type(data.data) == 'table' and data.page and data.total_pages
end)
if not data then return end
local subs = itable_filter(data.data, function(sub)
return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and
#sub.attributes.files > 0
end)
local items = itable_map(subs, function(sub)
local hints = {sub.attributes.language}
if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end
if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end
return {
title = sub.attributes.release,
hint = table.concat(hints, ', '),
value = {kind = 'file', id = sub.attributes.files[1].file_id},
keep_open = true,
}
end)
if #items == 0 then
items = {
{title = t('no results'), align = 'center', muted = true, italic = true, selectable = false},
}
end
if data.page > 1 then
items[#items + 1] = {
title = t('Previous page'),
align = 'center',
bold = true,
italic = true,
icon = 'navigate_before',
keep_open = true,
value = {kind = 'page', query = query, page = data.page - 1},
}
end
if data.page < data.total_pages then
items[#items + 1] = {
title = t('Next page'),
align = 'center',
bold = true,
italic = true,
icon = 'navigate_next',
keep_open = true,
value = {kind = 'page', query = query, page = data.page + 1},
}
end
menu:update_items(items)
end)
end
local initial_items = {
{title = t('%s to search', 'ctrl+enter'), align = 'center', muted = true, italic = true, selectable = false},
}
menu = Menu:open(
{
type = menu_type,
title = t('enter query'),
items = initial_items,
search_style = 'palette',
on_search = handle_search,
search_debounce = 'submit',
search_suggestion = search_suggestion,
},
handle_select
)
end

317
mpv/scripts/uosc/lib/std.lua Executable file
View file

@ -0,0 +1,317 @@
--[[ 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
-- Handles common option and message inputs that need to be split by comma when strings.
---@param input string|string[]|nil
---@return string[]
function comma_split(input)
if not input then return {} end
if type(input) == 'table' then return itable_map(input, tostring) end
local str = tostring(input)
return str:match('^%s*$') and {} or split(str, ' *, *')
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 value any
---@return boolean
function itable_has(itable, value)
return itable_index_of(itable, value) ~= nil
end
---@param itable table
---@param compare fun(value: any, index: number): boolean|integer|string|nil
---@param from? number Where to start search, defaults to `1`.
---@param to? number Where to end search, defaults to `#itable`.
---@return number|nil index
---@return any|nil value
function itable_find(itable, compare, from, to)
from, to = from or 1, to or #itable
for index = from, to, from < to and 1 or -1 do
if index > 0 and index <= #itable and compare(itable[index], index) then
return index, itable[index]
end
end
end
---@param itable table
---@param decider fun(value: any, index: number): boolean|integer|string|nil
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_delete_value(itable, value)
for index = 1, #itable, 1 do
if itable[index] == value then table.remove(itable, index) end
end
return itable
end
---@param itable table
---@param transformer fun(value: any, index: number) : any
function itable_map(itable, transformer)
local result = {}
for index, value in ipairs(itable) do
result[index] = transformer(value, index)
end
return result
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 ...T[]|nil
---@return T[]
function itable_join(...)
local args, result = {...}, {}
for i = 1, select('#', ...) do
if args[i] then for _, value in ipairs(args[i]) do result[#result + 1] = value end 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
function itable_clear(itable)
for i = #itable, 1, -1 do itable[i] = nil end
end
---@generic T
---@param input table<T, any>
---@return T[]
function table_keys(input)
local keys = {}
for key, _ in pairs(input) do keys[#keys + 1] = key end
return keys
end
---@generic T
---@param input table<any, T>
---@return T[]
function table_values(input)
local values = {}
for _, value in pairs(input) do values[#values + 1] = value end
return values
end
---@generic T: table<any, any>
---@param target T
---@param ... T|nil
---@return T
function table_assign(target, ...)
local args = {...}
for i = 1, select('#', ...) do
if type(args[i]) == 'table' then for key, value in pairs(args[i]) do target[key] = value end end
end
return target
end
---@generic T: table<any, any>
---@param target T
---@param source T
---@param props string[]
---@return T
function table_assign_props(target, source, props)
for _, name in ipairs(props) do target[name] = source[name] end
return target
end
-- `table_assign({}, input)` without loosing types :(
---@generic T: table<any, any>
---@param input T
---@return T
function table_copy(input) return table_assign({}, input) end
-- Converts itable values into `table<value, true>` map.
---@param values any[]
function create_set(values)
local result = {}
for _, value in ipairs(values) do result[value] = true end
return result
end
---@generic T: any
---@param input string
---@param value_sanitizer? fun(value: string, key: string): T
---@return table<string, T>
function serialize_key_value_list(input, value_sanitizer)
local result, sanitize = {}, value_sanitizer or function(value) return value end
for _, key_value_pair in ipairs(comma_split(input)) do
local key, value = key_value_pair:match('^([%w_]+)=([%w%.]+)$')
if key and value then result[key] = sanitize(value, key) end
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
---@class CircularBuffer<T> : Class
CircularBuffer = class()
function CircularBuffer:new(max_size) return Class.new(self, max_size) --[[@as CircularBuffer]] end
function CircularBuffer:init(max_size)
self.max_size = max_size
self.pos = 0
self.data = {}
end
function CircularBuffer:insert(item)
self.pos = self.pos % self.max_size + 1
self.data[self.pos] = item
end
function CircularBuffer:get(i)
return i <= #self.data and self.data[(self.pos + i - 1) % #self.data + 1] or nil
end
local function iter(self, i)
if i == #self.data then return nil end
i = i + 1
return i, self:get(i)
end
function CircularBuffer:iter()
return iter, self, 0
end
local function iter_rev(self, i)
if i == 1 then return nil end
i = i - 1
return i, self:get(i)
end
function CircularBuffer:iter_rev()
return iter_rev, self, #self.data + 1
end
function CircularBuffer:head()
return self.data[self.pos]
end
function CircularBuffer:tail()
if #self.data < 1 then return nil end
return self.data[self.pos % #self.data + 1]
end
function CircularBuffer:clear()
itable_clear(self.data)
self.pos = 0
end

515
mpv/scripts/uosc/lib/text.lua Executable file
View file

@ -0,0 +1,515 @@
-- 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)
local max_bytes = #str - i + 1
if char_byte < 0xC0 then
return math.min(max_bytes, 1)
elseif char_byte < 0xE0 then
return math.min(max_bytes, 2)
elseif char_byte < 0xF0 then
return math.min(max_bytes, 3)
elseif char_byte < 0xF8 then
return math.min(max_bytes, 4)
else
return math.min(max_bytes, 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?
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
---Estimating string length based on the number of characters
---@param char string
---@return number
function utf8_length(str)
local str_length = 0
for _, c in utf8_iter(str) do
str_length = str_length + 1
end
return str_length
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, math.huge}
return 0, math.huge
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 = math.huge
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 config.refine.text_width 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 = {}
function timestamp_zero_rep_clear_cache()
cache = {}
end
---Replace all timestamp digits with 0
---@param timestamp string
function timestamp_zero_rep(timestamp)
local substitute = cache[#timestamp]
if not substitute then
substitute = timestamp:gsub('%d', '0')
cache[#timestamp] = substitute
end
return substitute
end
---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)
return text_width(timestamp_zero_rep(timestamp), opts)
end
end
do
local wrap_at_chars = {' ', ' ', '-', ''}
local remove_when_wrap = {' ', ' '}
---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, integer
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, remove_when_wrap = wrap_at_chars, remove_when_wrap
local lines = {}
for _, text_line in ipairs(split(text, '\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 char_width = character_width(char, bold) * scale_factor
line_width = line_width + char_width
if (char_end == #text_line) or itable_has(wrap_at_chars, char) then
local remove = itable_has(remove_when_wrap, char)
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'), #lines
end
end
do
local word_separators = create_set({
' ', ' ', '\t', '-', '', '_', ',', '.', '+', '&', '(', ')', '[', ']', '{', '}', '<', '>', '/', '\\',
'', '', '', '', '', '', '', '', '', '', '', '', '', '',
})
---Get the first character of each word
---@param str string
---@return string[]
function initials(str)
local initials, is_word_start, word_separators = {}, true, word_separators
for _, char in utf8_iter(str) do
if word_separators[char] then
is_word_start = true
elseif is_word_start then
initials[#initials + 1] = char
is_word_start = false
end
end
return initials
end
end

907
mpv/scripts/uosc/lib/utils.lua Executable file
View file

@ -0,0 +1,907 @@
--[[ UI specific utilities that might or might not depend on its state or options ]]
---@alias Point {x: number; y: number}
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
---@alias Circle {point: Point, r: number, window_drag?: boolean}
---@alias Hitbox Rect|Circle
--- In place sorting of filenames
---@param filenames string[]
-- String sorting
do
----- winapi start -----
-- in windows system, we can use the sorting function provided by the win32 API
-- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
-- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
local winapi = nil
if state.platform == 'windows' and config.refine.sorting then
-- is_ffi_loaded is false usually means the mpv builds without luajit
local is_ffi_loaded, ffi = pcall(require, 'ffi')
if is_ffi_loaded then
winapi = {
ffi = ffi,
C = ffi.C,
CP_UTF8 = 65001,
shlwapi = ffi.load('shlwapi'),
}
-- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
ffi.cdef [[
int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
]]
winapi.utf8_to_wide = function(utf8_str)
if utf8_str then
local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)
if utf16_len > 0 then
local utf16_str = winapi.ffi.new('wchar_t[?]', utf16_len)
if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
return utf16_str
end
end
end
return ''
end
end
end
----- winapi end -----
-- alphanum sorting for humans in Lua
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function padnum(n, d)
return #d > 0 and ('%03d%s%.12f'):format(#n, n, tonumber(d) / (10 ^ #d))
or ('%03d%s'):format(#n, n)
end
local function sort_lua(strings)
local tuples = {}
for i, f in ipairs(strings) do
tuples[i] = {f:lower():gsub('0*(%d+)%.?(%d*)', padnum), f}
end
table.sort(tuples, function(a, b)
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
end)
for i, tuple in ipairs(tuples) do strings[i] = tuple[2] end
return strings
end
---@param strings string[]
function sort_strings(strings)
if winapi then
table.sort(strings, function(a, b)
return winapi.shlwapi.StrCmpLogicalW(winapi.utf8_to_wide(a), winapi.utf8_to_wide(b)) == -1
end)
else
sort_lua(strings)
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 duration_or_callback? number|fun() Duration in milliseconds or a callback function.
---@param callback? fun() Called either on animation end, or when animation is killed.
function tween(from, to, setter, duration_or_callback, callback)
local duration = duration_or_callback
if type(duration_or_callback) == 'function' then callback = duration_or_callback end
if type(duration) ~= 'number' then duration = options.animation_duration end
local current, done, timeout = from, false, nil
local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
local distance = math.abs(get_to() - current)
local cutoff = distance * 0.01
local target_ticks = (math.max(duration, 1) / (state.render_delay * 1000))
local decay = 1 - ((cutoff / distance) ^ (1 / target_ticks))
local function finish()
if not done then
setter(get_to())
done = true
timeout:kill()
if callback then callback() end
request_render()
end
end
local function tick()
local to = get_to()
current = current + ((to - current) * decay)
local is_end = math.abs(to - current) <= cutoff
if is_end then
finish()
else
setter(current)
timeout:resume()
request_render()
end
end
timeout = mp.add_timeout(state.render_delay, tick)
if cutoff > 0 then tick() else finish() end
return finish
end
---@param point Point
---@param rect Rect
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 Point
---@param point_b Point
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
---@param point Point
---@param hitbox Hitbox
function point_collides_with(point, hitbox)
return (hitbox.r and get_point_to_point_proximity(point, hitbox.point) <= hitbox.r) or
(not hitbox.r and get_point_to_rectangle_proximity(point, hitbox --[[@as Rect]]) == 0)
end
---@param lax number
---@param lay number
---@param lbx number
---@param lby number
---@param max number
---@param may number
---@param mbx number
---@param mby number
function get_line_to_line_intersection(lax, lay, lbx, lby, max, may, mbx, mby)
-- Calculate the direction of the lines
local uA = ((mbx - max) * (lay - may) - (mby - may) * (lax - max)) /
((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
local uB = ((lbx - lax) * (lay - may) - (lby - lay) * (lax - max)) /
((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
-- If uA and uB are between 0-1, lines are colliding
if uA >= 0 and uA <= 1 and uB >= 0 and uB <= 1 then
return lax + (uA * (lbx - lax)), lay + (uA * (lby - lay))
end
return nil, nil
end
-- Returns distance from the start of a finite ray assumed to be at (rax, ray)
-- coordinates to a line.
---@param rax number
---@param ray number
---@param rbx number
---@param rby number
---@param lax number
---@param lay number
---@param lbx number
---@param lby number
function get_ray_to_line_distance(rax, ray, rbx, rby, lax, lay, lbx, lby)
local x, y = get_line_to_line_intersection(rax, ray, rbx, rby, lax, lay, lbx, lby)
if x then
return math.sqrt((rax - x) ^ 2 + (ray - y) ^ 2)
end
return nil
end
-- Returns distance from the start of a finite ray assumed to be at (ax, ay)
-- coordinates to a rectangle. Returns `0` if ray originates inside rectangle.
---@param ax number
---@param ay number
---@param bx number
---@param by number
---@param rect Rect
---@return number|nil
function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
-- Is inside
if ax >= rect.ax and ax <= rect.bx and ay >= rect.ay and ay <= rect.by then
return 0
end
local closest = nil
local function updateDistance(distance)
if distance and (not closest or distance < closest) then closest = distance end
end
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.bx, rect.ay))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.bx, rect.ay, rect.bx, rect.by))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.by, rect.bx, rect.by))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.ax, rect.by))
return closest
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[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= 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 opts? {types?: string[], hidden?: boolean}
---@return string[]|nil files
---@return string[]|nil directories
function read_directory(path, opts)
opts = opts or {}
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 ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
local info = utils.file_info(join_path(path, item))
if info then
if info.is_file then
if not opts.types or has_any_extension(item, opts.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 opts? {types?: string[], hidden?: boolean}
function get_adjacent_files(file_path, opts)
opts = opts or {}
local current_meta = serialize_path(file_path)
if not current_meta then return end
local files = read_directory(current_meta.dirname, {hidden = opts.hidden})
if not files then return end
sort_strings(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 opts.types or has_any_extension(file, opts.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 paths table
---@param current_index number
---@param delta number 1 or -1 for forward or backward
function decide_navigation_in_list(paths, current_index, delta)
if #paths < 2 then return end
delta = delta < 0 and -1 or 1
-- Shuffle looks at the played files history trimmed to 80% length of the paths
-- and removes all paths in it from the potential shuffle pool. This guarantees
-- no path repetition until at least 80% of the playlist has been exhausted.
if state.shuffle then
state.shuffle_history = state.shuffle_history or {
pos = #state.history,
paths = itable_slice(state.history),
}
state.shuffle_history.pos = state.shuffle_history.pos + delta
local history_path = state.shuffle_history.paths[state.shuffle_history.pos]
local next_index = history_path and itable_index_of(paths, history_path)
if next_index then
return next_index, history_path
end
if delta < 0 then
state.shuffle_history.pos = state.shuffle_history.pos - delta
else
state.shuffle_history.pos = math.min(state.shuffle_history.pos, #state.shuffle_history.paths + 1)
end
local trimmed_history = itable_slice(state.history, -math.floor(#paths * 0.8))
local shuffle_pool = {}
for index, value in ipairs(paths) do
if not itable_has(trimmed_history, value) then
shuffle_pool[#shuffle_pool + 1] = index
end
end
math.randomseed(os.time())
local next_index = shuffle_pool[math.random(#shuffle_pool)]
local next_path = paths[next_index]
table.insert(state.shuffle_history.paths, state.shuffle_history.pos, next_path)
return next_index, next_path
end
local new_index = current_index + delta
if mp.get_property_native('loop-playlist') then
if new_index > #paths then
new_index = new_index % #paths
elseif new_index < 1 then
new_index = #paths - new_index
end
elseif new_index < 1 or new_index > #paths then
return
end
return new_index, paths[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, {
types = config.types.autoload,
hidden = options.show_hidden_files,
})
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 paths = itable_map(playlist, function(item) return normalize_path(item.filename) end)
local index = decide_navigation_in_list(paths, 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 delete_file_navigate(delta)
local path, playlist_pos = state.path, state.playlist_pos
local is_local_file = path and not is_protocol(path)
if navigate_item(delta) then
if state.has_playlist then
mp.commandv('playlist-remove', playlist_pos - 1)
end
else
mp.command('stop')
end
if is_local_file then
if Menu:is_open('open-file') then
Elements:maybe('menu', 'delete_value', path)
end
delete_file(path)
end
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_assign({}, 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 math.huge,
}, 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 math.huge,
}, 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
local chapter_number = chapter.title and string.match(chapter.title, '^Chapter (%d+)$')
if chapter_number then
chapter.title = t('Chapter %s', tonumber(chapter_number))
end
chapter.title = chapter.title ~= '(unnamed)' and chapter.title ~= '' and chapter.title or t('Chapter %s', 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, chapter.title_lines = 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
---Find all active key bindings or the active key binding for key
---@param key string|nil
---@return {[string]: table}|table
function find_active_keybindings(key)
local bindings = mp.get_property_native('input-bindings', {})
local active = {} -- map: key-name -> bind-info
for _, bind in pairs(bindings) do
if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
not active[bind.key]
or (active[bind.key].is_weak and not bind.is_weak)
or (bind.is_weak == active[bind.key].is_weak and bind.priority > active[bind.key].priority)
)
then
active[bind.key] = bind
end
end
return not key and active or active[key]
end
---@param type 'sub'|'audio'|'video'
---@param path string
function load_track(type, path)
mp.commandv(type .. '-add', path, 'cached')
-- If subtitle track was loaded, assume the user also wants to see it
if type == 'sub' then
mp.commandv('set', 'sub-visibility', 'yes')
end
end
---@return string|nil
function get_clipboard()
local result = mp.command_native({
name = 'subprocess',
capture_stderr = true,
capture_stdout = true,
playback_only = false,
args = {config.ziggy_path, 'get-clipboard'},
})
local function print_error(message)
msg.error('Getting clipboard data failed. Error: ' .. message)
end
if result.status == 0 then
local data = utils.parse_json(result.stdout)
if data and data.payload then
return data.payload
else
print_error(data and (data.error and data.message or 'unknown error') or 'couldn\'t parse json')
end
else
print_error('exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr)
end
end
--[[ RENDERING ]]
function render()
if not display.initialized then return end
state.render_last_time = mp.get_time()
cursor:clear_zones()
-- Click on empty area detection
if setup_click_detection then setup_click_detection() end
-- Actual rendering
local ass = assdraw.ass_new()
-- Idle indicator
if state.is_idle and not Manager.disabled.idle_indicator then
local smaller_side = math.min(display.width, display.height)
local center_x, center_y, icon_size = display.width / 2, display.height / 2, math.max(smaller_side / 4, 56)
ass:icon(center_x, center_y - icon_size / 4, icon_size, 'not_started', {
color = fg, opacity = config.opacity.idle_indicator,
})
ass:txt(center_x, center_y + icon_size / 2, 8, t('Drop files or URLs to play here'), {
size = icon_size / 4, color = fg, opacity = config.opacity.idle_indicator,
})
end
-- Audio indicator
if state.is_audio and not state.has_image and not Manager.disabled.audio_indicator
and not (state.pause and options.pause_indicator == 'static') then
local smaller_side = math.min(display.width, display.height)
ass:icon(display.width / 2, display.height / 2, smaller_side / 4, 'graphic_eq', {
color = fg, opacity = config.opacity.audio_indicator,
})
end
-- Elements
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