refactor: modularize the codebase
Splits the monolith file into individual modules with as little code refactoring as possible. Not too happy with it, mainly because of the weird language server behavior where it doesn't recognize types or variables unless the files defining them are open... It'd also be better to refactor everything from the ground up to not depend on global variables but if I had that much time to invest I'd straight up just rewrite everything in typescript and use TS→Lua transpiler.
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -13,6 +13,7 @@
|
||||
"autorenew",
|
||||
"bestaudio",
|
||||
"bestvideo",
|
||||
"Bopomofo",
|
||||
"demux",
|
||||
"doubleclick",
|
||||
"gfps",
|
||||
|
5248
scripts/uosc.lua
5248
scripts/uosc.lua
File diff suppressed because it is too large
Load Diff
39
scripts/uosc/elements/BufferingIndicator.lua
Normal file
39
scripts/uosc/elements/BufferingIndicator.lua
Normal file
@@ -0,0 +1,39 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@class BufferingIndicator : Element
|
||||
local BufferingIndicator = class(Element)
|
||||
|
||||
function BufferingIndicator:new() return Class.new(self) --[[@as BufferingIndicator]] end
|
||||
function BufferingIndicator:init()
|
||||
Element.init(self, 'buffer_indicator')
|
||||
self.ignores_menu = true
|
||||
self.enabled = false
|
||||
end
|
||||
|
||||
function BufferingIndicator:decide_enabled()
|
||||
local cache = state.cache_underrun or state.cache_buffering and state.cache_buffering < 100
|
||||
local player = state.core_idle and not state.eof_reached
|
||||
if self.enabled then
|
||||
if not player or (state.pause and not cache) then self.enabled = false end
|
||||
elseif player and cache and state.uncached_ranges then self.enabled = true end
|
||||
end
|
||||
|
||||
function BufferingIndicator:on_prop_pause() self:decide_enabled() end
|
||||
function BufferingIndicator:on_prop_core_idle() self:decide_enabled() end
|
||||
function BufferingIndicator:on_prop_eof_reached() self:decide_enabled() end
|
||||
function BufferingIndicator:on_prop_uncached_ranges() self:decide_enabled() end
|
||||
function BufferingIndicator:on_prop_cache_buffering() self:decide_enabled() end
|
||||
function BufferingIndicator:on_prop_cache_underrun() self:decide_enabled() end
|
||||
|
||||
function BufferingIndicator:render()
|
||||
local ass = assdraw.ass_new()
|
||||
ass:rect(0, 0, display.width, display.height, {color = bg, opacity = 0.3})
|
||||
local size = round(40 + math.min(display.width, display.height) / 8)
|
||||
local opacity = (Elements.menu and not Elements.menu.is_closing) and 0.3 or nil
|
||||
local opts = {rotate = (state.render_last_time * 2 % 1) * -360, color = fg, opacity = opacity}
|
||||
ass:icon(display.width / 2, display.height / 2, size, 'autorenew', opts)
|
||||
request_render()
|
||||
return ass
|
||||
end
|
||||
|
||||
return BufferingIndicator
|
88
scripts/uosc/elements/Button.lua
Normal file
88
scripts/uosc/elements/Button.lua
Normal file
@@ -0,0 +1,88 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
|
||||
|
||||
---@class Button : Element
|
||||
local Button = class(Element)
|
||||
|
||||
---@param id string
|
||||
---@param props ButtonProps
|
||||
function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
|
||||
---@param id string
|
||||
---@param props ButtonProps
|
||||
function Button:init(id, props)
|
||||
self.icon = props.icon
|
||||
self.active = props.active
|
||||
self.tooltip = props.tooltip
|
||||
self.badge = props.badge
|
||||
self.foreground = props.foreground or fg
|
||||
self.background = props.background or bg
|
||||
---@type fun()
|
||||
self.on_click = props.on_click
|
||||
Element.init(self, id, props)
|
||||
end
|
||||
|
||||
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
||||
function Button:on_mbtn_left_down()
|
||||
-- We delay the callback to next tick, otherwise we are risking race
|
||||
-- conditions as we are in the middle of event dispatching.
|
||||
-- For example, handler might add a menu to the end of the element stack, and that
|
||||
-- than picks up this click even we are in right now, and instantly closes itself.
|
||||
mp.add_timeout(0.01, self.on_click)
|
||||
end
|
||||
|
||||
function Button:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local is_hover = self.proximity_raw == 0
|
||||
local is_hover_or_active = is_hover or self.active
|
||||
local foreground = self.active and self.background or self.foreground
|
||||
local background = self.active and self.foreground or self.background
|
||||
|
||||
-- Background
|
||||
if is_hover_or_active then
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
||||
color = self.active and background or foreground, radius = 2,
|
||||
opacity = visibility * (self.active and 1 or 0.3),
|
||||
})
|
||||
end
|
||||
|
||||
-- Tooltip on hover
|
||||
if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
|
||||
|
||||
|
||||
-- Badge
|
||||
local icon_clip
|
||||
if self.badge then
|
||||
local badge_font_size = self.font_size * 0.6
|
||||
local badge_opts = {size = badge_font_size, color = background, opacity = visibility}
|
||||
local badge_width = text_width(self.badge, badge_opts)
|
||||
local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
|
||||
local bx, by = self.bx - 1, self.by - 1
|
||||
ass:rect(bx - width, by - height, bx, by, {
|
||||
color = foreground, radius = 2, opacity = visibility,
|
||||
border = self.active and 0 or 1, border_color = background,
|
||||
})
|
||||
ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts)
|
||||
|
||||
local clip_border = math.max(self.font_size / 20, 1)
|
||||
local clip_path = assdraw.ass_new()
|
||||
clip_path:round_rect_cw(
|
||||
math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3
|
||||
)
|
||||
icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')'
|
||||
end
|
||||
|
||||
-- Icon
|
||||
local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2)
|
||||
ass:icon(x, y, self.font_size, self.icon, {
|
||||
color = foreground, border = self.active and 0 or options.text_border, border_color = background,
|
||||
opacity = visibility, clip = icon_clip,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Button
|
329
scripts/uosc/elements/Controls.lua
Normal file
329
scripts/uosc/elements/Controls.lua
Normal file
@@ -0,0 +1,329 @@
|
||||
local Element = require('elements/Element')
|
||||
local Button = require('elements/Button')
|
||||
local CycleButton = require('elements/CycleButton')
|
||||
local Speed = require('elements/Speed')
|
||||
|
||||
-- `scale` - `options.controls_size` scale factor.
|
||||
-- `ratio` - Width/height ratio of a static or dynamic element.
|
||||
-- `ratio_min` Min ratio for 'dynamic' sized element.
|
||||
---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table<string, boolean>}
|
||||
|
||||
---@class Controls : Element
|
||||
local Controls = class(Element)
|
||||
|
||||
function Controls:new() return Class.new(self) --[[@as Controls]] end
|
||||
function Controls:init()
|
||||
Element.init(self, 'controls')
|
||||
---@type ControlItem[] All control elements serialized from `options.controls`.
|
||||
self.controls = {}
|
||||
---@type ControlItem[] Only controls that match current dispositions.
|
||||
self.layout = {}
|
||||
|
||||
-- Serialize control elements
|
||||
local shorthands = {
|
||||
menu = 'command:menu:script-binding uosc/menu-blurred?Menu',
|
||||
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?Subtitles',
|
||||
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?Audio',
|
||||
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device',
|
||||
video = 'command:theaters:script-binding uosc/video#video>1?Video',
|
||||
playlist = 'command:list_alt:script-binding uosc/playlist?Playlist',
|
||||
chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?Chapters',
|
||||
['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?Editions',
|
||||
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?Stream quality',
|
||||
['open-file'] = 'command:file_open:script-binding uosc/open-file?Open file',
|
||||
['items'] = 'command:list_alt:script-binding uosc/items?Playlist/Files',
|
||||
prev = 'command:arrow_back_ios:script-binding uosc/prev?Previous',
|
||||
next = 'command:arrow_forward_ios:script-binding uosc/next?Next',
|
||||
first = 'command:first_page:script-binding uosc/first?First',
|
||||
last = 'command:last_page:script-binding uosc/last?Last',
|
||||
['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?Loop playlist',
|
||||
['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?Loop file',
|
||||
shuffle = 'toggle:shuffle:shuffle?Shuffle',
|
||||
fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen',
|
||||
}
|
||||
|
||||
-- Parse out disposition/config pairs
|
||||
local items = {}
|
||||
local in_disposition = false
|
||||
local current_item = nil
|
||||
for c in options.controls:gmatch('.') do
|
||||
if not current_item then current_item = {disposition = '', config = ''} end
|
||||
if c == '<' and #current_item.config == 0 then in_disposition = true
|
||||
elseif c == '>' and #current_item.config == 0 then in_disposition = false
|
||||
elseif c == ',' and not in_disposition then
|
||||
items[#items + 1] = current_item
|
||||
current_item = nil
|
||||
else
|
||||
local prop = in_disposition and 'disposition' or 'config'
|
||||
current_item[prop] = current_item[prop] .. c
|
||||
end
|
||||
end
|
||||
items[#items + 1] = current_item
|
||||
|
||||
-- Create controls
|
||||
self.controls = {}
|
||||
for i, item in ipairs(items) do
|
||||
local config = shorthands[item.config] and shorthands[item.config] or item.config
|
||||
local config_tooltip = split(config, ' *%? *')
|
||||
local tooltip = config_tooltip[2]
|
||||
config = shorthands[config_tooltip[1]]
|
||||
and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
|
||||
local config_badge = split(config, ' *# *')
|
||||
config = config_badge[1]
|
||||
local badge = config_badge[2]
|
||||
local parts = split(config, ' *: *')
|
||||
local kind, params = parts[1], itable_slice(parts, 2)
|
||||
|
||||
-- Serialize dispositions
|
||||
local dispositions = {}
|
||||
for _, definition in ipairs(split(item.disposition, ' *, *')) do
|
||||
if #definition > 0 then
|
||||
local value = definition:sub(1, 1) ~= '!'
|
||||
local name = not value and definition:sub(2) or definition
|
||||
local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
|
||||
dispositions[prop] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Convert toggles into cycles
|
||||
if kind == 'toggle' then
|
||||
kind = 'cycle'
|
||||
params[#params + 1] = 'no/yes!'
|
||||
end
|
||||
|
||||
-- Create a control element
|
||||
local control = {dispositions = dispositions, kind = kind}
|
||||
|
||||
if kind == 'space' then
|
||||
control.sizing = 'space'
|
||||
elseif kind == 'gap' then
|
||||
table_assign(control, {sizing = 'dynamic', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
|
||||
elseif kind == 'command' then
|
||||
if #params ~= 2 then
|
||||
mp.error(string.format(
|
||||
'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
|
||||
))
|
||||
else
|
||||
local element = Button:new('control_' .. i, {
|
||||
icon = params[1],
|
||||
anchor_id = 'controls',
|
||||
on_click = function() mp.command(params[2]) end,
|
||||
tooltip = tooltip,
|
||||
count_prop = 'sub',
|
||||
})
|
||||
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||
if badge then self:register_badge_updater(badge, element) end
|
||||
end
|
||||
elseif kind == 'cycle' then
|
||||
if #params ~= 3 then
|
||||
mp.error(string.format(
|
||||
'cycle button needs 3 parameters, %d received: %s',
|
||||
#params, table.concat(params, '/')
|
||||
))
|
||||
else
|
||||
local state_configs = split(params[3], ' */ *')
|
||||
local states = {}
|
||||
|
||||
for _, state_config in ipairs(state_configs) do
|
||||
local active = false
|
||||
if state_config:sub(-1) == '!' then
|
||||
active = true
|
||||
state_config = state_config:sub(1, -2)
|
||||
end
|
||||
local state_params = split(state_config, ' *= *')
|
||||
local value, icon = state_params[1], state_params[2] or params[1]
|
||||
states[#states + 1] = {value = value, icon = icon, active = active}
|
||||
end
|
||||
|
||||
local element = CycleButton:new('control_' .. i, {
|
||||
prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip,
|
||||
})
|
||||
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||
if badge then self:register_badge_updater(badge, element) end
|
||||
end
|
||||
elseif kind == 'speed' then
|
||||
if not Elements.speed then
|
||||
local element = Speed:new({anchor_id = 'controls'})
|
||||
table_assign(control, {
|
||||
element = element, sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2,
|
||||
})
|
||||
else
|
||||
msg.error('there can only be 1 speed slider')
|
||||
end
|
||||
else
|
||||
msg.error('unknown element kind "' .. kind .. '"')
|
||||
break
|
||||
end
|
||||
|
||||
self.controls[#self.controls + 1] = control
|
||||
end
|
||||
|
||||
self:reflow()
|
||||
end
|
||||
|
||||
function Controls:reflow()
|
||||
-- Populate the layout only with items that match current disposition
|
||||
self.layout = {}
|
||||
for _, control in ipairs(self.controls) do
|
||||
local matches = true
|
||||
for prop, value in pairs(control.dispositions) do
|
||||
if state[prop] ~= value then
|
||||
matches = false
|
||||
break
|
||||
end
|
||||
end
|
||||
if control.element then control.element.enabled = matches end
|
||||
if matches then self.layout[#self.layout + 1] = control end
|
||||
end
|
||||
|
||||
self:update_dimensions()
|
||||
Elements:trigger('controls_reflow')
|
||||
end
|
||||
|
||||
---@param badge string
|
||||
---@param element Element An element that supports `badge` property.
|
||||
function Controls:register_badge_updater(badge, element)
|
||||
local prop_and_limit = split(badge, ' *> *')
|
||||
local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
|
||||
local observable_name, serializer, is_external_prop = prop, nil, false
|
||||
|
||||
if itable_index_of({'sub', 'audio', 'video'}, prop) then
|
||||
observable_name = 'track-list'
|
||||
serializer = function(value)
|
||||
local count = 0
|
||||
for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
|
||||
return count
|
||||
end
|
||||
else
|
||||
local parts = split(prop, '@')
|
||||
-- Support both new `prop@owner` and old `@prop` syntaxes
|
||||
if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end
|
||||
serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
|
||||
end
|
||||
|
||||
local function handler(_, value)
|
||||
local new_value = serializer(value) --[[@as nil|string|integer]]
|
||||
local value_number = tonumber(new_value)
|
||||
if value_number then new_value = value_number > limit and value_number or nil end
|
||||
element.badge = new_value
|
||||
request_render()
|
||||
end
|
||||
|
||||
if is_external_prop then element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end
|
||||
else mp.observe_property(observable_name, 'native', handler) end
|
||||
end
|
||||
|
||||
function Controls:get_visibility()
|
||||
local timeline_is_hovered = Elements.timeline.enabled and Elements.timeline.proximity_raw == 0
|
||||
return (Elements.speed and Elements.speed.dragging) and 1 or timeline_is_hovered
|
||||
and -1 or Element.get_visibility(self)
|
||||
end
|
||||
|
||||
function Controls:update_dimensions()
|
||||
local window_border = Elements.window_border.size
|
||||
local size = state.fullormaxed and options.controls_size_fullscreen or options.controls_size
|
||||
local spacing = options.controls_spacing
|
||||
local margin = options.controls_margin
|
||||
|
||||
-- Disable when not enough space
|
||||
local available_space = display.height - Elements.window_border.size * 2
|
||||
if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end
|
||||
if Elements.timeline.enabled then available_space = available_space - Elements.timeline.size_max end
|
||||
self.enabled = available_space > size + 10
|
||||
|
||||
-- Reset hide/enabled flags
|
||||
for c, control in ipairs(self.layout) do
|
||||
control.hide = false
|
||||
if control.element then control.element.enabled = self.enabled end
|
||||
end
|
||||
|
||||
if not self.enabled then return end
|
||||
|
||||
-- Container
|
||||
self.bx = display.width - window_border - margin
|
||||
self.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin
|
||||
self.ax, self.ay = window_border + margin, self.by - size
|
||||
|
||||
-- Controls
|
||||
local available_width = self.bx - self.ax
|
||||
local statics_width = (#self.layout - 1) * spacing
|
||||
local min_content_width = statics_width
|
||||
local max_dynamics_width, dynamic_units, spaces = 0, 0, 0
|
||||
|
||||
-- Calculate statics_width, min_content_width, and count spaces
|
||||
for c, control in ipairs(self.layout) do
|
||||
if control.sizing == 'space' then
|
||||
spaces = spaces + 1
|
||||
elseif control.sizing == 'static' then
|
||||
local width = size * control.scale * control.ratio
|
||||
statics_width = statics_width + width
|
||||
min_content_width = min_content_width + width
|
||||
elseif control.sizing == 'dynamic' then
|
||||
min_content_width = min_content_width + size * control.scale * control.ratio_min
|
||||
max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
|
||||
dynamic_units = dynamic_units + control.scale * control.ratio
|
||||
end
|
||||
end
|
||||
|
||||
-- Hide & disable elements in the middle until we fit into available width
|
||||
if min_content_width > available_width then
|
||||
local i = math.ceil(#self.layout / 2 + 0.1)
|
||||
for a = 0, #self.layout - 1, 1 do
|
||||
i = i + (a * (a % 2 == 0 and 1 or -1))
|
||||
local control = self.layout[i]
|
||||
|
||||
if control.kind ~= 'gap' and control.kind ~= 'space' then
|
||||
control.hide = true
|
||||
if control.element then control.element.enabled = false end
|
||||
if control.sizing == 'static' then
|
||||
local width = size * control.scale * control.ratio
|
||||
min_content_width = min_content_width - width - spacing
|
||||
statics_width = statics_width - width - spacing
|
||||
elseif control.sizing == 'dynamic' then
|
||||
min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
|
||||
max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
|
||||
dynamic_units = dynamic_units - control.scale * control.ratio
|
||||
end
|
||||
|
||||
if min_content_width < available_width then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Lay out the elements
|
||||
local current_x = self.ax
|
||||
local width_for_dynamics = available_width - statics_width
|
||||
local space_width = (width_for_dynamics - max_dynamics_width) / spaces
|
||||
|
||||
for c, control in ipairs(self.layout) do
|
||||
if not control.hide then
|
||||
local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
|
||||
local width, height = 0, 0
|
||||
|
||||
if sizing == 'space' then
|
||||
if space_width > 0 then width = space_width end
|
||||
elseif sizing == 'static' then
|
||||
height = size * scale
|
||||
width = height * ratio
|
||||
elseif sizing == 'dynamic' then
|
||||
height = size * scale
|
||||
width = max_dynamics_width < width_for_dynamics
|
||||
and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
|
||||
end
|
||||
|
||||
local bx = current_x + width
|
||||
if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
|
||||
current_x = bx + spacing
|
||||
end
|
||||
end
|
||||
|
||||
Elements:update_proximities()
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Controls:on_dispositions() self:reflow() end
|
||||
function Controls:on_display() self:update_dimensions() end
|
||||
function Controls:on_prop_border() self:update_dimensions() end
|
||||
function Controls:on_prop_fullormaxed() self:update_dimensions() end
|
||||
|
||||
return Controls
|
35
scripts/uosc/elements/Curtain.lua
Normal file
35
scripts/uosc/elements/Curtain.lua
Normal file
@@ -0,0 +1,35 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@class Curtain : Element
|
||||
local Curtain = class(Element)
|
||||
|
||||
function Curtain:new() return Class.new(self) --[[@as Curtain]] end
|
||||
function Curtain:init()
|
||||
Element.init(self, 'curtain', {ignores_menu = true})
|
||||
self.opacity = 0
|
||||
---@type string[]
|
||||
self.dependents = {}
|
||||
end
|
||||
|
||||
---@param id string
|
||||
function Curtain:register(id)
|
||||
self.dependents[#self.dependents + 1] = id
|
||||
if #self.dependents == 1 then self:tween_property('opacity', self.opacity, 1) end
|
||||
end
|
||||
|
||||
---@param id string
|
||||
function Curtain:unregister(id)
|
||||
self.dependents = itable_filter(self.dependents, function(item) return item ~= id end)
|
||||
if #self.dependents == 0 then self:tween_property('opacity', self.opacity, 0) end
|
||||
end
|
||||
|
||||
function Curtain:render()
|
||||
if self.opacity == 0 or options.curtain_opacity == 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
ass:rect(0, 0, display.width, display.height, {
|
||||
color = '000000', opacity = options.curtain_opacity * self.opacity,
|
||||
})
|
||||
return ass
|
||||
end
|
||||
|
||||
return Curtain
|
64
scripts/uosc/elements/CycleButton.lua
Normal file
64
scripts/uosc/elements/CycleButton.lua
Normal file
@@ -0,0 +1,64 @@
|
||||
local Button = require('elements/Button')
|
||||
|
||||
---@alias CycleState {value: any; icon: string; active?: boolean}
|
||||
---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
|
||||
|
||||
---@class CycleButton : Button
|
||||
local CycleButton = class(Button)
|
||||
|
||||
---@param id string
|
||||
---@param props CycleButtonProps
|
||||
function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
|
||||
---@param id string
|
||||
---@param props CycleButtonProps
|
||||
function CycleButton:init(id, props)
|
||||
local is_state_prop = itable_index_of({'shuffle'}, props.prop)
|
||||
self.prop = props.prop
|
||||
self.states = props.states
|
||||
|
||||
Button.init(self, id, props)
|
||||
|
||||
self.icon = self.states[1].icon
|
||||
self.active = self.states[1].active
|
||||
self.current_state_index = 1
|
||||
self.on_click = function()
|
||||
local new_state = self.states[self.current_state_index + 1] or self.states[1]
|
||||
local new_value = new_state.value
|
||||
if self.owner then
|
||||
mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
|
||||
elseif is_state_prop then
|
||||
if itable_index_of({'yes', 'no'}, new_value) then new_value = new_value == 'yes' end
|
||||
set_state(self.prop, new_value)
|
||||
else
|
||||
mp.set_property(self.prop, new_value)
|
||||
end
|
||||
end
|
||||
|
||||
self.handle_change = function(name, value)
|
||||
if is_state_prop and type(value) == 'boolean' then value = value and 'yes' or 'no' end
|
||||
local index = itable_find(self.states, function(state) return state.value == value end)
|
||||
self.current_state_index = index or 1
|
||||
self.icon = self.states[self.current_state_index].icon
|
||||
self.active = self.states[self.current_state_index].active
|
||||
request_render()
|
||||
end
|
||||
|
||||
local prop_parts = split(self.prop, '@')
|
||||
if #prop_parts == 2 then -- External prop with a script owner
|
||||
self.prop, self.owner = prop_parts[1], prop_parts[2]
|
||||
self['on_external_prop_' .. self.prop] = function(_, value) self.handle_change(self.prop, value) end
|
||||
self.handle_change(self.prop, external[self.prop])
|
||||
elseif is_state_prop then -- uosc's state props
|
||||
self['on_prop_' .. self.prop] = function(self, value) self.handle_change(self.prop, value) end
|
||||
self.handle_change(self.prop, state[self.prop])
|
||||
else
|
||||
mp.observe_property(self.prop, 'string', self.handle_change)
|
||||
end
|
||||
end
|
||||
|
||||
function CycleButton:destroy()
|
||||
Button.destroy(self)
|
||||
mp.unobserve_property(self.handle_change)
|
||||
end
|
||||
|
||||
return CycleButton
|
148
scripts/uosc/elements/Element.lua
Normal file
148
scripts/uosc/elements/Element.lua
Normal file
@@ -0,0 +1,148 @@
|
||||
---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;}
|
||||
|
||||
-- Base class all elements inherit from.
|
||||
---@class Element : Class
|
||||
local Element = class()
|
||||
|
||||
---@param id string
|
||||
---@param props? ElementProps
|
||||
function Element:init(id, props)
|
||||
self.id = id
|
||||
-- `false` means element won't be rendered, or receive events
|
||||
self.enabled = true
|
||||
-- Element coordinates
|
||||
self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0
|
||||
-- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range.
|
||||
self.proximity = 0
|
||||
-- Raw proximity in pixels.
|
||||
self.proximity_raw = infinity
|
||||
---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility.
|
||||
self.min_visibility = 0
|
||||
---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations
|
||||
self.forced_visibility = nil
|
||||
---@type boolean Render this element even when menu is open.
|
||||
self.ignores_menu = false
|
||||
---@type nil|string ID of an element from which this one should inherit visibility.
|
||||
self.anchor_id = nil
|
||||
|
||||
if props then table_assign(self, props) end
|
||||
|
||||
-- Flash timer
|
||||
self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
|
||||
local getTo = function() return self.proximity end
|
||||
self:tween_property('forced_visibility', 1, getTo, function()
|
||||
self.forced_visibility = nil
|
||||
end)
|
||||
end)
|
||||
self._flash_out_timer:kill()
|
||||
|
||||
Elements:add(self)
|
||||
end
|
||||
|
||||
function Element:destroy()
|
||||
self.destroyed = true
|
||||
Elements:remove(self)
|
||||
end
|
||||
|
||||
---@param ax number
|
||||
---@param ay number
|
||||
---@param bx number
|
||||
---@param by number
|
||||
function Element:set_coordinates(ax, ay, bx, by)
|
||||
self.ax, self.ay, self.bx, self.by = ax, ay, bx, by
|
||||
Elements:update_proximities()
|
||||
self:maybe('on_coordinates')
|
||||
end
|
||||
|
||||
function Element:update_proximity()
|
||||
if cursor.hidden then
|
||||
self.proximity_raw = infinity
|
||||
self.proximity = 0
|
||||
else
|
||||
local range = options.proximity_out - options.proximity_in
|
||||
self.proximity_raw = get_point_to_rectangle_proximity(cursor, self)
|
||||
self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range)
|
||||
end
|
||||
end
|
||||
|
||||
-- Decide elements visibility based on proximity and various other factors
|
||||
function Element:get_visibility()
|
||||
-- Hide when menu is open, unless this is a menu
|
||||
---@diagnostic disable-next-line: undefined-global
|
||||
if not self.ignores_menu and Menu and Menu:is_open() then return 0 end
|
||||
|
||||
-- Persistency
|
||||
local persist = config[self.id .. '_persistency']
|
||||
if persist and (
|
||||
(persist.audio and state.is_audio)
|
||||
or (persist.paused and state.pause)
|
||||
or (persist.video and state.is_video)
|
||||
or (persist.image and state.is_image)
|
||||
or (persist.idle and state.is_idle)
|
||||
) then return 1 end
|
||||
|
||||
-- Forced visibility
|
||||
if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end
|
||||
|
||||
-- Anchor inheritance
|
||||
-- If anchor returns -1, it means all attached elements should force hide.
|
||||
local anchor = self.anchor_id and Elements[self.anchor_id]
|
||||
local anchor_visibility = anchor and anchor:get_visibility() or 0
|
||||
|
||||
return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility)
|
||||
end
|
||||
|
||||
-- Call method if it exists
|
||||
function Element:maybe(name, ...)
|
||||
if self[name] then return self[name](self, ...) end
|
||||
end
|
||||
|
||||
-- Attach a tweening animation to this element
|
||||
---@param from number
|
||||
---@param to number|fun():number
|
||||
---@param setter fun(value: number)
|
||||
---@param factor_or_callback? number|fun()
|
||||
---@param callback? fun() Called either on animation end, or when animation is killed.
|
||||
function Element:tween(from, to, setter, factor_or_callback, callback)
|
||||
self:tween_stop()
|
||||
self._kill_tween = self.enabled and tween(
|
||||
from, to, setter, factor_or_callback,
|
||||
function()
|
||||
self._kill_tween = nil
|
||||
if callback then callback() end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
function Element:is_tweening() return self and self._kill_tween end
|
||||
function Element:tween_stop() self:maybe('_kill_tween') end
|
||||
|
||||
-- Animate an element property between 2 values.
|
||||
---@param prop string
|
||||
---@param from number
|
||||
---@param to number|fun():number
|
||||
---@param factor_or_callback? number|fun()
|
||||
---@param callback? fun() Called either on animation end, or when animation is killed.
|
||||
function Element:tween_property(prop, from, to, factor_or_callback, callback)
|
||||
self:tween(from, to, function(value) self[prop] = value end, factor_or_callback, callback)
|
||||
end
|
||||
|
||||
---@param name string
|
||||
function Element:trigger(name, ...)
|
||||
self:maybe('on_' .. name, ...)
|
||||
request_render()
|
||||
end
|
||||
|
||||
-- Briefly flashes the element for `options.flash_duration` milliseconds.
|
||||
-- Useful to visualize changes of volume and timeline when changed via hotkeys.
|
||||
function Element:flash()
|
||||
if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
|
||||
self:tween_stop()
|
||||
self.forced_visibility = 1
|
||||
request_render()
|
||||
self._flash_out_timer:kill()
|
||||
self._flash_out_timer:resume()
|
||||
end
|
||||
end
|
||||
|
||||
return Element
|
154
scripts/uosc/elements/Elements.lua
Normal file
154
scripts/uosc/elements/Elements.lua
Normal file
@@ -0,0 +1,154 @@
|
||||
local Elements = {itable = {}}
|
||||
|
||||
---@param element Element
|
||||
function Elements:add(element)
|
||||
if not element.id then
|
||||
msg.error('attempt to add element without "id" property')
|
||||
return
|
||||
end
|
||||
|
||||
if self:has(element.id) then Elements:remove(element.id) end
|
||||
|
||||
self.itable[#self.itable + 1] = element
|
||||
self[element.id] = element
|
||||
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Elements:remove(idOrElement)
|
||||
if not idOrElement then return end
|
||||
local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement
|
||||
local element = Elements[id]
|
||||
if element then
|
||||
if not element.destroyed then element:destroy() end
|
||||
element.enabled = false
|
||||
self.itable = itable_remove(self.itable, self[id])
|
||||
self[id] = nil
|
||||
request_render()
|
||||
end
|
||||
end
|
||||
|
||||
function Elements:update_proximities()
|
||||
local capture_mbtn_left = false
|
||||
local capture_wheel = false
|
||||
local menu_only = Elements.menu ~= nil
|
||||
local mouse_leave_elements = {}
|
||||
local mouse_enter_elements = {}
|
||||
|
||||
-- Calculates proximities and opacities for defined elements
|
||||
for _, element in self:ipairs() do
|
||||
if element.enabled then
|
||||
local previous_proximity_raw = element.proximity_raw
|
||||
|
||||
-- If menu is open, all other elements have to be disabled
|
||||
if menu_only then
|
||||
if element.ignores_menu then
|
||||
capture_mbtn_left = true
|
||||
capture_wheel = true
|
||||
element:update_proximity()
|
||||
else
|
||||
element.proximity_raw = infinity
|
||||
element.proximity = 0
|
||||
end
|
||||
else
|
||||
element:update_proximity()
|
||||
end
|
||||
|
||||
-- Element has global forced key listeners
|
||||
if element.on_global_mbtn_left_down then capture_mbtn_left = true end
|
||||
if element.on_global_wheel_up or element.on_global_wheel_down then capture_wheel = true end
|
||||
|
||||
if element.proximity_raw == 0 then
|
||||
-- Element has local forced key listeners
|
||||
if element.on_mbtn_left_down then capture_mbtn_left = true end
|
||||
if element.on_wheel_up or element.on_wheel_up then capture_wheel = true end
|
||||
|
||||
-- Mouse entered element area
|
||||
if previous_proximity_raw ~= 0 then
|
||||
mouse_enter_elements[#mouse_enter_elements + 1] = element
|
||||
end
|
||||
else
|
||||
-- Mouse left element area
|
||||
if previous_proximity_raw == 0 then
|
||||
mouse_leave_elements[#mouse_leave_elements + 1] = element
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Enable key group captures requested by elements
|
||||
mp[capture_mbtn_left and 'enable_key_bindings' or 'disable_key_bindings']('mbtn_left')
|
||||
mp[capture_wheel and 'enable_key_bindings' or 'disable_key_bindings']('wheel')
|
||||
|
||||
-- Trigger `mouse_leave` and `mouse_enter` events
|
||||
for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end
|
||||
for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end
|
||||
end
|
||||
|
||||
-- Toggles passed elements' min visibilities between 0 and 1.
|
||||
---@param ids string[] IDs of elements to peek.
|
||||
function Elements:toggle(ids)
|
||||
local has_invisible = itable_find(ids, function(id) return Elements[id] and Elements[id].min_visibility ~= 1 end)
|
||||
self:set_min_visibility(has_invisible and 1 or 0, ids)
|
||||
end
|
||||
|
||||
-- Set (animate) elements' min visibilities to passed value.
|
||||
---@param visibility number 0-1 floating point.
|
||||
---@param ids string[] IDs of elements to peek.
|
||||
function Elements:set_min_visibility(visibility, ids)
|
||||
for _, id in ipairs(ids) do
|
||||
local element = Elements[id]
|
||||
if element then element:tween_property('min_visibility', element.min_visibility, visibility) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Flash passed elements.
|
||||
---@param ids string[] IDs of elements to peek.
|
||||
function Elements:flash(ids)
|
||||
local elements = itable_filter(self.itable, function(element) return itable_index_of(ids, element.id) ~= nil end)
|
||||
for _, element in ipairs(elements) do element:flash() end
|
||||
end
|
||||
|
||||
---@param name string Event name.
|
||||
function Elements:trigger(name, ...)
|
||||
for _, element in self:ipairs() do element:trigger(name, ...) end
|
||||
end
|
||||
|
||||
-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity.
|
||||
-- Disabled elements don't receive these events.
|
||||
---@param name string Event name.
|
||||
function Elements:proximity_trigger(name, ...)
|
||||
for _, element in self:ipairs() do
|
||||
if element.enabled then
|
||||
if element.proximity_raw == 0 then element:trigger(name, ...) end
|
||||
element:trigger('global_' .. name, ...)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Elements:has(id) return self[id] ~= nil end
|
||||
function Elements:ipairs() return ipairs(self.itable) end
|
||||
|
||||
---@param name string Event name.
|
||||
function Elements:create_proximity_dispatcher(name)
|
||||
return function(...) self:proximity_trigger(name, ...) end
|
||||
end
|
||||
|
||||
mp.set_key_bindings({
|
||||
{
|
||||
'mbtn_left',
|
||||
Elements:create_proximity_dispatcher('mbtn_left_up'),
|
||||
function(...)
|
||||
update_mouse_pos(nil, mp.get_property_native('mouse-pos'), true)
|
||||
Elements:proximity_trigger('mbtn_left_down', ...)
|
||||
end,
|
||||
},
|
||||
{'mbtn_left_dbl', 'ignore'},
|
||||
}, 'mbtn_left', 'force')
|
||||
|
||||
mp.set_key_bindings({
|
||||
{'wheel_up', Elements:create_proximity_dispatcher('wheel_up')},
|
||||
{'wheel_down', Elements:create_proximity_dispatcher('wheel_down')},
|
||||
}, 'wheel', 'force')
|
||||
|
||||
return Elements
|
767
scripts/uosc/elements/Menu.lua
Normal file
767
scripts/uosc/elements/Menu.lua
Normal file
@@ -0,0 +1,767 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
-- Menu data structure accepted by `Menu:open(menu)`.
|
||||
---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected_index?: integer;}
|
||||
---@alias MenuDataItem MenuDataValue|MenuData
|
||||
---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; keep_open?: boolean; separator?: boolean;}
|
||||
---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(), on_close?: fun()}
|
||||
|
||||
-- Internal data structure created from `Menu`.
|
||||
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling}
|
||||
---@alias MenuStackItem MenuStackValue|MenuStack
|
||||
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_width: number; hint_width: number}
|
||||
---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
|
||||
|
||||
---@class Menu : Element
|
||||
local Menu = class(Element)
|
||||
|
||||
---@param data MenuData
|
||||
---@param callback fun(value: any)
|
||||
---@param opts? MenuOptions
|
||||
function Menu:open(data, callback, opts)
|
||||
local open_menu = self:is_open()
|
||||
if open_menu then
|
||||
open_menu.is_being_replaced = true
|
||||
open_menu:close(true)
|
||||
end
|
||||
return Menu:new(data, callback, opts)
|
||||
end
|
||||
|
||||
---@param menu_type? string
|
||||
---@return Menu|nil
|
||||
function Menu:is_open(menu_type)
|
||||
return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil
|
||||
end
|
||||
|
||||
---@param immediate? boolean Close immediately without fadeout animation.
|
||||
---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed.
|
||||
---@overload fun(callback: fun())
|
||||
function Menu:close(immediate, callback)
|
||||
if type(immediate) ~= 'boolean' then callback = immediate end
|
||||
|
||||
local menu = self == Menu and Elements.menu or self
|
||||
|
||||
if menu and not menu.destroyed then
|
||||
if menu.is_closing then
|
||||
menu:tween_stop()
|
||||
return
|
||||
end
|
||||
|
||||
local function close()
|
||||
Elements:remove('menu')
|
||||
menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {}
|
||||
menu:disable_key_bindings()
|
||||
Elements:update_proximities()
|
||||
if callback then callback() end
|
||||
request_render()
|
||||
end
|
||||
|
||||
menu.is_closing = true
|
||||
|
||||
if immediate then close()
|
||||
else menu:fadeout(close) end
|
||||
end
|
||||
end
|
||||
|
||||
---@param data MenuData
|
||||
---@param callback fun(value: any)
|
||||
---@param opts? MenuOptions
|
||||
---@return Menu
|
||||
function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end
|
||||
---@param data MenuData
|
||||
---@param callback fun(value: any)
|
||||
---@param opts? MenuOptions
|
||||
function Menu:init(data, callback, opts)
|
||||
Element.init(self, 'menu', {ignores_menu = true})
|
||||
|
||||
-----@type fun()
|
||||
self.callback = callback
|
||||
self.opts = opts or {}
|
||||
self.offset_x = 0 -- Used for submenu transition animation.
|
||||
self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
|
||||
self.item_height = nil
|
||||
self.item_spacing = 1
|
||||
self.item_padding = nil
|
||||
self.font_size = nil
|
||||
self.font_size_hint = nil
|
||||
self.scroll_step = nil -- Item height + item spacing.
|
||||
self.scroll_height = nil -- Items + spacings - container height.
|
||||
self.opacity = 0 -- Used to fade in/out.
|
||||
self.type = data.type
|
||||
---@type MenuStack Root MenuStack.
|
||||
self.root = nil
|
||||
---@type MenuStack Current MenuStack.
|
||||
self.current = nil
|
||||
---@type MenuStack[] All menus in a flat array.
|
||||
self.all = nil
|
||||
---@type table<string, MenuStack> Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
|
||||
self.by_id = {}
|
||||
self.key_bindings = {}
|
||||
self.is_being_replaced = false
|
||||
self.is_closing = false
|
||||
---@type {y: integer, time: number}[]
|
||||
self.drag_data = nil
|
||||
self.is_dragging = false
|
||||
|
||||
self:update(data)
|
||||
|
||||
if self.mouse_nav then
|
||||
if self.current then self.current.selected_index = nil end
|
||||
else
|
||||
for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu) end
|
||||
end
|
||||
|
||||
self:tween_property('opacity', 0, 1)
|
||||
self:enable_key_bindings()
|
||||
Elements.curtain:register('menu')
|
||||
if self.opts.on_open then self.opts.on_open() end
|
||||
end
|
||||
|
||||
function Menu:destroy()
|
||||
Element.destroy(self)
|
||||
self:disable_key_bindings()
|
||||
if not self.is_being_replaced then Elements.curtain:unregister('menu') end
|
||||
if self.opts.on_close then self.opts.on_close() end
|
||||
end
|
||||
|
||||
---@param data MenuData
|
||||
function Menu:update(data)
|
||||
self.type = data.type
|
||||
|
||||
local new_root = {is_root = true}
|
||||
local new_all = {}
|
||||
local new_by_id = {}
|
||||
local menus_to_serialize = {{new_root, data}}
|
||||
local old_current_id = self.current and self.current.id
|
||||
|
||||
table_assign(new_root, data, {'title', 'hint', 'keep_open'})
|
||||
|
||||
local i = 0
|
||||
while i < #menus_to_serialize do
|
||||
i = i + 1
|
||||
local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2]
|
||||
local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id
|
||||
if not menu.is_root then
|
||||
menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
|
||||
end
|
||||
menu.icon = 'chevron_right'
|
||||
|
||||
-- Update items
|
||||
local first_active_index = nil
|
||||
menu.items = {}
|
||||
|
||||
for i, item_data in ipairs(menu_data.items or {}) do
|
||||
if item_data.active and not first_active_index then first_active_index = i end
|
||||
|
||||
local item = {}
|
||||
table_assign(item, item_data, {
|
||||
'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator',
|
||||
})
|
||||
if item.keep_open == nil then item.keep_open = menu.keep_open end
|
||||
|
||||
-- Submenu
|
||||
if item_data.items then
|
||||
item.parent_menu = menu
|
||||
menus_to_serialize[#menus_to_serialize + 1] = {item, item_data}
|
||||
end
|
||||
|
||||
menu.items[i] = item
|
||||
end
|
||||
|
||||
if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end
|
||||
|
||||
-- Retain old state
|
||||
local old_menu = self.by_id[menu.is_root and '__root__' or menu.id]
|
||||
if old_menu then table_assign(menu, old_menu, {'selected_index', 'scroll_y', 'fling'}) end
|
||||
|
||||
new_all[#new_all + 1] = menu
|
||||
new_by_id[menu.is_root and '__root__' or menu.id] = menu
|
||||
end
|
||||
|
||||
self.root, self.all, self.by_id = new_root, new_all, new_by_id
|
||||
self.current = self.by_id[old_current_id] or self.root
|
||||
|
||||
self:update_content_dimensions()
|
||||
self:reset_navigation()
|
||||
end
|
||||
|
||||
---@param items MenuDataItem[]
|
||||
function Menu:update_items(items)
|
||||
local data = table_shallow_copy(self.root)
|
||||
data.items = items
|
||||
self:update(data)
|
||||
end
|
||||
|
||||
function Menu:update_content_dimensions()
|
||||
self.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height
|
||||
self.font_size = round(self.item_height * 0.48 * options.font_scale)
|
||||
self.font_size_hint = self.font_size - 1
|
||||
self.item_padding = round((self.item_height - self.font_size) * 0.6)
|
||||
self.scroll_step = self.item_height + self.item_spacing
|
||||
|
||||
local title_opts = {size = self.font_size, italic = false, bold = false}
|
||||
local hint_opts = {size = self.font_size_hint}
|
||||
|
||||
for _, menu in ipairs(self.all) do
|
||||
-- Estimate width of a widest item
|
||||
local max_width = 0
|
||||
for _, item in ipairs(menu.items) do
|
||||
local icon_width = item.icon and self.font_size or 0
|
||||
item.title_width = text_width(item.title, title_opts)
|
||||
item.hint_width = text_width(item.hint, hint_opts)
|
||||
local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0)
|
||||
+ (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0)
|
||||
local estimated_width = item.title_width + item.hint_width + icon_width
|
||||
+ (self.item_padding * spacings_in_item)
|
||||
if estimated_width > max_width then max_width = estimated_width end
|
||||
end
|
||||
|
||||
-- Also check menu title
|
||||
title_opts.bold, title_opts.italic = true, false
|
||||
local menu_title_width = text_width(menu.title, title_opts)
|
||||
if menu_title_width > max_width then max_width = menu_title_width end
|
||||
|
||||
menu.max_width = max_width
|
||||
end
|
||||
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function Menu:update_dimensions()
|
||||
-- Coordinates and sizes are of the scrollable area to make
|
||||
-- consuming values in rendering and collisions easier. Title drawn above this, so
|
||||
-- we need to account for that in max_height and ay position.
|
||||
local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width
|
||||
|
||||
for _, menu in ipairs(self.all) do
|
||||
menu.width = round(clamp(min_width, menu.max_width, display.width * 0.9))
|
||||
local title_height = (menu.is_root and menu.title) and self.scroll_step or 0
|
||||
local max_height = round((display.height - title_height) * 0.9)
|
||||
local content_height = self.scroll_step * #menu.items
|
||||
menu.height = math.min(content_height - self.item_spacing, max_height)
|
||||
menu.top = round(math.max((display.height - menu.height) / 2, title_height * 1.5))
|
||||
menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
|
||||
menu.scroll_y = menu.scroll_y or 0
|
||||
self:scroll_to(menu.scroll_y, menu) -- clamps scroll_y to scroll limits
|
||||
end
|
||||
|
||||
local ax = round((display.width - self.current.width) / 2) + self.offset_x
|
||||
self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
|
||||
end
|
||||
|
||||
function Menu:reset_navigation()
|
||||
local menu = self.current
|
||||
|
||||
-- Reset indexes and scroll
|
||||
self:scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits
|
||||
if self.mouse_nav then
|
||||
self:select_item_below_cursor()
|
||||
else
|
||||
self:select_index((menu.items and #menu.items > 0) and clamp(1, menu.selected_index or 1, #menu.items) or nil)
|
||||
end
|
||||
|
||||
-- Walk up the parent menu chain and activate items that lead to current menu
|
||||
local parent = menu.parent_menu
|
||||
while parent do
|
||||
parent.selected_index = itable_index_of(parent.items, menu)
|
||||
menu, parent = parent, parent.parent_menu
|
||||
end
|
||||
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Menu:set_offset_x(offset)
|
||||
local delta = offset - self.offset_x
|
||||
self.offset_x = offset
|
||||
self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by)
|
||||
end
|
||||
|
||||
function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
|
||||
|
||||
function Menu:get_item_index_below_cursor()
|
||||
local menu = self.current
|
||||
if #menu.items < 1 or self.proximity_raw > 0 then return nil end
|
||||
return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items))
|
||||
end
|
||||
|
||||
function Menu:get_first_active_index(menu)
|
||||
menu = menu or self.current
|
||||
for index, item in ipairs(self.current.items) do
|
||||
if item.active then return index end
|
||||
end
|
||||
end
|
||||
|
||||
---@param pos? number
|
||||
---@param menu? MenuStack
|
||||
function Menu:set_scroll_to(pos, menu)
|
||||
menu = menu or self.current
|
||||
menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param delta? number
|
||||
---@param menu? MenuStack
|
||||
function Menu:set_scroll_by(delta, menu)
|
||||
menu = menu or self.current
|
||||
self:set_scroll_to(menu.scroll_y + delta, menu)
|
||||
end
|
||||
|
||||
---@param pos? number
|
||||
---@param menu? MenuStack
|
||||
---@param fling_options? table
|
||||
function Menu:scroll_to(pos, menu, fling_options)
|
||||
menu = menu or self.current
|
||||
menu.fling = {
|
||||
y = menu.scroll_y, distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y),
|
||||
time = mp.get_time(), duration = 0.1, easing = ease_out_sext,
|
||||
}
|
||||
if fling_options then table_assign(menu.fling, fling_options) end
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param delta? number
|
||||
---@param menu? MenuStack
|
||||
---@param fling_options? Fling
|
||||
function Menu:scroll_by(delta, menu, fling_options)
|
||||
menu = menu or self.current
|
||||
self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu, fling_options)
|
||||
end
|
||||
|
||||
---@param index? integer
|
||||
---@param menu? MenuStack
|
||||
function Menu:scroll_to_index(index, menu)
|
||||
menu = menu or self.current
|
||||
if (index and index >= 1 and index <= #menu.items) then
|
||||
self:scroll_to(round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2)), menu)
|
||||
end
|
||||
end
|
||||
|
||||
---@param index? integer
|
||||
---@param menu? MenuStack
|
||||
function Menu:select_index(index, menu)
|
||||
menu = menu or self.current
|
||||
menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param value? any
|
||||
---@param menu? MenuStack
|
||||
function Menu:select_value(value, menu)
|
||||
menu = menu or self.current
|
||||
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
||||
self:select_index(index, 5)
|
||||
end
|
||||
|
||||
---@param menu? MenuStack
|
||||
function Menu:deactivate_items(menu)
|
||||
menu = menu or self.current
|
||||
for _, item in ipairs(menu.items) do item.active = false end
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param index? integer
|
||||
---@param menu? MenuStack
|
||||
function Menu:activate_index(index, menu)
|
||||
menu = menu or self.current
|
||||
if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param index? integer
|
||||
---@param menu? MenuStack
|
||||
function Menu:activate_unique_index(index, menu)
|
||||
self:deactivate_items(menu)
|
||||
self:activate_index(index, menu)
|
||||
end
|
||||
|
||||
---@param value? any
|
||||
---@param menu? MenuStack
|
||||
function Menu:activate_value(value, menu)
|
||||
menu = menu or self.current
|
||||
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
||||
self:activate_index(index, menu)
|
||||
end
|
||||
|
||||
---@param value? any
|
||||
---@param menu? MenuStack
|
||||
function Menu:activate_unique_value(value, menu)
|
||||
menu = menu or self.current
|
||||
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
||||
self:activate_unique_index(index, menu)
|
||||
end
|
||||
|
||||
---@param id string
|
||||
function Menu:activate_submenu(id)
|
||||
local submenu = self.by_id[id]
|
||||
if submenu then
|
||||
self.current = submenu
|
||||
request_render()
|
||||
else
|
||||
msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id))
|
||||
end
|
||||
self:reset_navigation()
|
||||
end
|
||||
|
||||
---@param index? integer
|
||||
---@param menu? MenuStack
|
||||
function Menu:delete_index(index, menu)
|
||||
menu = menu or self.current
|
||||
if (index and index >= 1 and index <= #menu.items) then
|
||||
table.remove(menu.items, index)
|
||||
self:update_content_dimensions()
|
||||
self:scroll_to_index(menu.selected_index, menu)
|
||||
end
|
||||
end
|
||||
|
||||
---@param value? any
|
||||
---@param menu? MenuStack
|
||||
function Menu:delete_value(value, menu)
|
||||
menu = menu or self.current
|
||||
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
||||
self:delete_index(index)
|
||||
end
|
||||
|
||||
---@param menu? MenuStack
|
||||
function Menu:prev(menu)
|
||||
menu = menu or self.current
|
||||
menu.selected_index = math.max(menu.selected_index and menu.selected_index - 1 or #menu.items, 1)
|
||||
self:scroll_to_index(menu.selected_index, menu)
|
||||
end
|
||||
|
||||
---@param menu? MenuStack
|
||||
function Menu:next(menu)
|
||||
menu = menu or self.current
|
||||
menu.selected_index = math.min(menu.selected_index and menu.selected_index + 1 or 1, #menu.items)
|
||||
self:scroll_to_index(menu.selected_index, menu)
|
||||
end
|
||||
|
||||
function Menu:back()
|
||||
local menu = self.current
|
||||
local parent = menu.parent_menu
|
||||
|
||||
if not parent then return self:close() end
|
||||
|
||||
menu.selected_index = nil
|
||||
self.current = parent
|
||||
self:update_dimensions()
|
||||
self:tween(self.offset_x - menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
|
||||
self.opacity = 1 -- in case tween above canceled fade in animation
|
||||
end
|
||||
|
||||
---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean}
|
||||
function Menu:open_selected_item(opts)
|
||||
opts = opts or {}
|
||||
local menu = self.current
|
||||
if menu.selected_index then
|
||||
local item = menu.items[menu.selected_index]
|
||||
-- Is submenu
|
||||
if item.items then
|
||||
self.current = item
|
||||
if opts.preselect_submenu_item then
|
||||
item.selected_index = #item.items > 0 and 1 or nil
|
||||
end
|
||||
self:update_dimensions()
|
||||
self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
|
||||
self.opacity = 1 -- in case tween above canceled fade in animation
|
||||
else
|
||||
self.callback(item.value)
|
||||
if not item.keep_open and not opts.keep_open then self:close() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
|
||||
function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end
|
||||
function Menu:select_item_below_cursor() self.current.selected_index = self:get_item_index_below_cursor() end
|
||||
|
||||
function Menu:on_display() self:update_dimensions() end
|
||||
function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
|
||||
|
||||
function Menu:on_global_mbtn_left_down()
|
||||
if self.proximity_raw == 0 then
|
||||
self.drag_data = {{y = cursor.y, time = mp.get_time()}}
|
||||
self.current.fling = nil
|
||||
else
|
||||
if cursor.x < self.ax then self:back()
|
||||
else self:close() end
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:fling_distance()
|
||||
local first, last = self.drag_data[1], self.drag_data[#self.drag_data]
|
||||
if mp.get_time() - last.time > 0.05 then return 0 end
|
||||
for i = #self.drag_data - 1, 1, -1 do
|
||||
local drag = self.drag_data[i]
|
||||
if last.time - drag.time > 0.03 then return ((drag.y - last.y) / ((last.time - drag.time) / 0.03)) * 10 end
|
||||
end
|
||||
return #self.drag_data < 2 and 0 or ((first.y - last.y) / ((first.time - last.time) / 0.03)) * 10
|
||||
end
|
||||
|
||||
function Menu:on_global_mbtn_left_up()
|
||||
if self.proximity_raw == 0 and not self.is_dragging then
|
||||
self:select_item_below_cursor()
|
||||
self:open_selected_item({preselect_submenu_item = false})
|
||||
end
|
||||
if self.is_dragging then
|
||||
local distance = self:fling_distance()
|
||||
if math.abs(distance) > 50 then
|
||||
self.current.fling = {
|
||||
y = self.current.scroll_y, distance = distance, time = self.drag_data[#self.drag_data].time,
|
||||
easing = ease_out_quart, duration = 0.5, update_cursor = true
|
||||
}
|
||||
end
|
||||
end
|
||||
self.is_dragging = false
|
||||
self.drag_data = nil
|
||||
end
|
||||
|
||||
|
||||
function Menu:on_global_mouse_move()
|
||||
self.mouse_nav = true
|
||||
if self.drag_data then
|
||||
self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_data[1].y) >= 10
|
||||
local distance = self.drag_data[#self.drag_data].y - cursor.y
|
||||
if distance ~= 0 then self:set_scroll_by(distance) end
|
||||
self.drag_data[#self.drag_data + 1] = {y = cursor.y, time = mp.get_time()}
|
||||
end
|
||||
if self.proximity_raw == 0 or self.is_dragging then self:select_item_below_cursor()
|
||||
else self.current.selected_index = nil end
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Menu:on_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end
|
||||
function Menu:on_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end
|
||||
|
||||
function Menu:on_pgup()
|
||||
local menu = self.current
|
||||
local items_per_page = round((menu.height / self.scroll_step) * 0.4)
|
||||
local paged_index = (menu.selected_index and menu.selected_index or #menu.items) - items_per_page
|
||||
menu.selected_index = clamp(1, paged_index, #menu.items)
|
||||
if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
|
||||
end
|
||||
|
||||
function Menu:on_pgdwn()
|
||||
local menu = self.current
|
||||
local items_per_page = round((menu.height / self.scroll_step) * 0.4)
|
||||
local paged_index = (menu.selected_index and menu.selected_index or 1) + items_per_page
|
||||
menu.selected_index = clamp(1, paged_index, #menu.items)
|
||||
if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
|
||||
end
|
||||
|
||||
function Menu:on_home()
|
||||
self.current.selected_index = math.min(1, #self.current.items)
|
||||
if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
|
||||
end
|
||||
|
||||
function Menu:on_end()
|
||||
self.current.selected_index = #self.current.items
|
||||
if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
|
||||
end
|
||||
|
||||
function Menu:add_key_binding(key, name, fn, flags)
|
||||
self.key_bindings[#self.key_bindings + 1] = name
|
||||
mp.add_forced_key_binding(key, name, fn, flags)
|
||||
end
|
||||
|
||||
function Menu:enable_key_bindings()
|
||||
-- The `mp.set_key_bindings()` method would be easier here, but that
|
||||
-- doesn't support 'repeatable' flag, so we are stuck with this monster.
|
||||
self:add_key_binding('up', 'menu-prev1', self:create_key_action('prev'), 'repeatable')
|
||||
self:add_key_binding('down', 'menu-next1', self:create_key_action('next'), 'repeatable')
|
||||
self:add_key_binding('left', 'menu-back1', self:create_key_action('back'))
|
||||
self:add_key_binding('right', 'menu-select1', self:create_key_action('open_selected_item_preselect'))
|
||||
self:add_key_binding('shift+right', 'menu-select-soft1', self:create_key_action('open_selected_item_soft'))
|
||||
self:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_key_action('open_selected_item_soft'))
|
||||
self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_key_action('back'))
|
||||
self:add_key_binding('bs', 'menu-back-alt4', self:create_key_action('back'))
|
||||
self:add_key_binding('enter', 'menu-select-alt3', self:create_key_action('open_selected_item_preselect'))
|
||||
self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_key_action('open_selected_item_preselect'))
|
||||
self:add_key_binding('shift+enter', 'menu-select-alt5', self:create_key_action('open_selected_item_soft'))
|
||||
self:add_key_binding('shift+kp_enter', 'menu-select-alt6', self:create_key_action('open_selected_item_soft'))
|
||||
self:add_key_binding('esc', 'menu-close', self:create_key_action('close'))
|
||||
self:add_key_binding('pgup', 'menu-page-up', self:create_key_action('on_pgup'))
|
||||
self:add_key_binding('pgdwn', 'menu-page-down', self:create_key_action('on_pgdwn'))
|
||||
self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
|
||||
self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
|
||||
end
|
||||
|
||||
function Menu:disable_key_bindings()
|
||||
for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end
|
||||
self.key_bindings = {}
|
||||
end
|
||||
|
||||
function Menu:create_key_action(name)
|
||||
return function(...)
|
||||
self.mouse_nav = false
|
||||
self:maybe(name, ...)
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:render()
|
||||
local update_cursor = false
|
||||
for _, menu in ipairs(self.all) do
|
||||
if menu.fling then
|
||||
update_cursor = update_cursor or menu.fling.update_cursor or false
|
||||
local time_delta = state.render_last_time - menu.fling.time
|
||||
local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1))
|
||||
self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu)
|
||||
if progress < 1 then request_render() else menu.fling = nil end
|
||||
end
|
||||
end
|
||||
if update_cursor then self:select_item_below_cursor() end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local opacity = options.menu_opacity * self.opacity
|
||||
local spacing = self.item_padding
|
||||
local icon_size = self.font_size
|
||||
|
||||
function draw_menu(menu, x, y, opacity)
|
||||
local ax, ay, bx, by = x, y, x + menu.width, y + menu.height
|
||||
local draw_title = menu.is_root and menu.title
|
||||
local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
|
||||
local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
|
||||
local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
|
||||
local selected_index = menu.selected_index or -1
|
||||
-- remove menu_opacity to start off with full opacity, but still decay for parent menus
|
||||
local text_opacity = opacity / options.menu_opacity
|
||||
|
||||
-- Background
|
||||
ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, {
|
||||
color = bg, opacity = opacity, radius = 4,
|
||||
})
|
||||
|
||||
for index = start_index, end_index, 1 do
|
||||
local item = menu.items[index]
|
||||
local next_item = menu.items[index + 1]
|
||||
local is_highlighted = selected_index == index or item.active
|
||||
local next_is_active = next_item and next_item.active
|
||||
local next_is_highlighted = selected_index == index + 1 or next_is_active
|
||||
|
||||
if not item then break end
|
||||
|
||||
local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
|
||||
local item_by = item_ay + self.item_height
|
||||
local item_center_y = item_ay + (self.item_height / 2)
|
||||
local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
|
||||
local content_ax, content_bx = ax + spacing, bx - spacing
|
||||
local font_color = item.active and fgt or bgt
|
||||
local shadow_color = item.active and fg or bg
|
||||
|
||||
-- Separator
|
||||
local separator_ay = item.separator and item_by - 1 or item_by
|
||||
local separator_by = item_by + (item.separator and 2 or 1)
|
||||
if is_highlighted then separator_ay = item_by + 1 end
|
||||
if next_is_highlighted then separator_by = item_by end
|
||||
if separator_by - separator_ay > 0 and item_by < by then
|
||||
ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, {
|
||||
color = fg, opacity = opacity * (item.separator and 0.08 or 0.06),
|
||||
})
|
||||
end
|
||||
|
||||
-- Highlight
|
||||
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0)
|
||||
if highlight_opacity > 0 then
|
||||
ass:rect(ax + 2, item_ay, bx - 2, item_by, {
|
||||
radius = 2, color = fg, opacity = highlight_opacity * text_opacity,
|
||||
clip = item_clip,
|
||||
})
|
||||
end
|
||||
|
||||
-- Icon
|
||||
if item.icon then
|
||||
ass:icon(content_bx - (icon_size / 2), item_center_y, icon_size * 1.5, item.icon, {
|
||||
color = font_color, opacity = text_opacity, clip = item_clip,
|
||||
shadow = 1, shadow_color = shadow_color,
|
||||
})
|
||||
content_bx = content_bx - icon_size - spacing
|
||||
end
|
||||
|
||||
local title_cut_x = content_bx
|
||||
if item.hint_width > 0 then
|
||||
-- controls title & hint clipping proportional to the ratio of their widths
|
||||
local title_content_ratio = item.title_width / (item.title_width + item.hint_width)
|
||||
title_cut_x = round(content_ax + (content_bx - content_ax - spacing) * title_content_ratio
|
||||
+ (item.title_width > 0 and spacing / 2 or 0))
|
||||
end
|
||||
|
||||
-- Hint
|
||||
if item.hint then
|
||||
item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
|
||||
local clip = '\\clip(' .. title_cut_x .. ',' ..
|
||||
math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')'
|
||||
ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
|
||||
size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * opacity, clip = clip,
|
||||
shadow = 1, shadow_color = shadow_color,
|
||||
})
|
||||
end
|
||||
|
||||
-- Title
|
||||
if item.title then
|
||||
item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
|
||||
local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
|
||||
.. title_cut_x .. ',' .. math.min(item_by, by) .. ')'
|
||||
ass:txt(content_ax, item_center_y, 4, item.ass_safe_title, {
|
||||
size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2,
|
||||
opacity = text_opacity * (item.muted and 0.5 or 1), clip = clip,
|
||||
shadow = 1, shadow_color = shadow_color,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Menu title
|
||||
if draw_title then
|
||||
local title_ay = ay - self.item_height
|
||||
local title_height = self.item_height - 3
|
||||
menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title)
|
||||
|
||||
-- Background
|
||||
ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, {
|
||||
color = fg, opacity = opacity * 0.8, radius = 2,
|
||||
})
|
||||
ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', {
|
||||
size = 80, color = bg, opacity = opacity * 0.1,
|
||||
})
|
||||
|
||||
-- Title
|
||||
ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.ass_safe_title, {
|
||||
size = self.font_size, bold = true, color = bg, wrap = 2, opacity = opacity,
|
||||
clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')',
|
||||
})
|
||||
end
|
||||
|
||||
-- Scrollbar
|
||||
if menu.scroll_height > 0 then
|
||||
local groove_height = menu.height - 2
|
||||
local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
|
||||
local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
|
||||
ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {color = fg, opacity = opacity * 0.8})
|
||||
end
|
||||
end
|
||||
|
||||
-- Main menu
|
||||
draw_menu(self.current, self.ax, self.ay, opacity)
|
||||
|
||||
-- Parent menus
|
||||
local parent_menu = self.current.parent_menu
|
||||
local parent_offset_x = self.ax
|
||||
local parent_opacity_factor = options.menu_parent_opacity
|
||||
local menu_gap = 2
|
||||
|
||||
while parent_menu do
|
||||
parent_offset_x = parent_offset_x - parent_menu.width - menu_gap
|
||||
draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity)
|
||||
parent_opacity_factor = parent_opacity_factor * parent_opacity_factor
|
||||
parent_menu = parent_menu.parent_menu
|
||||
end
|
||||
|
||||
-- Selected menu
|
||||
local selected_menu = self.current.items[self.current.selected_index]
|
||||
|
||||
if selected_menu and selected_menu.items then
|
||||
draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity)
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Menu
|
80
scripts/uosc/elements/PauseIndicator.lua
Normal file
80
scripts/uosc/elements/PauseIndicator.lua
Normal file
@@ -0,0 +1,80 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@class PauseIndicator : Element
|
||||
local PauseIndicator = class(Element)
|
||||
|
||||
function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end
|
||||
function PauseIndicator:init()
|
||||
Element.init(self, 'pause_indicator')
|
||||
self.ignores_menu = true
|
||||
self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
|
||||
self.paused = state.pause
|
||||
self.type = options.pause_indicator
|
||||
self.is_manual = options.pause_indicator == 'manual'
|
||||
self.fadeout_requested = false
|
||||
self.opacity = 0
|
||||
|
||||
mp.observe_property('pause', 'bool', function(_, paused)
|
||||
if Elements.timeline.pressed then return end
|
||||
if options.pause_indicator == 'flash' then
|
||||
if self.paused == paused then return end
|
||||
self:flash()
|
||||
elseif options.pause_indicator == 'static' then
|
||||
self:decide()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function PauseIndicator:flash()
|
||||
if not self.is_manual and self.type ~= 'flash' then return end
|
||||
-- can't wait for pause property event listener to set this, because when this is used inside a binding like:
|
||||
-- cycle pause; script-binding uosc/flash-pause-indicator
|
||||
-- the pause event is not fired fast enough, and indicator starts rendering with old icon
|
||||
self.paused = mp.get_property_native('pause')
|
||||
if self.is_manual then self.type = 'flash' end
|
||||
self.opacity = 1
|
||||
self:tween_property('opacity', 1, 0, 0.15)
|
||||
end
|
||||
|
||||
-- decides whether static indicator should be visible or not
|
||||
function PauseIndicator:decide()
|
||||
if not self.is_manual and self.type ~= 'static' then return end
|
||||
self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
|
||||
if self.is_manual then self.type = 'static' end
|
||||
self.opacity = self.paused and 1 or 0
|
||||
request_render()
|
||||
|
||||
-- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored.
|
||||
-- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more
|
||||
mp.add_timeout(.05, function() osd:update() end)
|
||||
end
|
||||
|
||||
function PauseIndicator:render()
|
||||
if self.opacity == 0 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local is_static = self.type == 'static'
|
||||
|
||||
-- Background fadeout
|
||||
if is_static then
|
||||
ass:rect(0, 0, display.width, display.height, {color = bg, opacity = self.opacity * 0.3})
|
||||
end
|
||||
|
||||
-- Icon
|
||||
local size = round(math.min(display.width, display.height) * (is_static and 0.20 or 0.15))
|
||||
size = size + size * (1 - self.opacity)
|
||||
|
||||
if self.paused then
|
||||
ass:icon(display.width / 2, display.height / 2, size, 'pause',
|
||||
{border = 1, opacity = self.base_icon_opacity * self.opacity}
|
||||
)
|
||||
else
|
||||
ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow',
|
||||
{border = 1, opacity = self.base_icon_opacity * self.opacity}
|
||||
)
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return PauseIndicator
|
181
scripts/uosc/elements/Speed.lua
Normal file
181
scripts/uosc/elements/Speed.lua
Normal file
@@ -0,0 +1,181 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
|
||||
|
||||
---@class Speed : Element
|
||||
local Speed = class(Element)
|
||||
|
||||
---@param props? ElementProps
|
||||
function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
|
||||
function Speed:init(props)
|
||||
Element.init(self, 'speed', props)
|
||||
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.notches = 10
|
||||
self.notch_every = 0.1
|
||||
---@type number
|
||||
self.notch_spacing = nil
|
||||
---@type number
|
||||
self.font_size = nil
|
||||
---@type Dragging|nil
|
||||
self.dragging = nil
|
||||
end
|
||||
|
||||
function Speed:on_coordinates()
|
||||
self.height, self.width = self.by - self.ay, self.bx - self.ax
|
||||
self.notch_spacing = self.width / (self.notches + 1)
|
||||
self.font_size = round(self.height * 0.48 * options.font_scale)
|
||||
end
|
||||
|
||||
function Speed:speed_step(speed, up)
|
||||
if options.speed_step_is_factor then
|
||||
if up then
|
||||
return speed * options.speed_step
|
||||
else
|
||||
return speed * 1 / options.speed_step
|
||||
end
|
||||
else
|
||||
if up then
|
||||
return speed + options.speed_step
|
||||
else
|
||||
return speed - options.speed_step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Speed:on_mbtn_left_down()
|
||||
self:tween_stop() -- Stop and cleanup possible ongoing animations
|
||||
self.dragging = {
|
||||
start_time = mp.get_time(),
|
||||
start_x = cursor.x,
|
||||
distance = 0,
|
||||
speed_distance = 0,
|
||||
start_speed = state.speed,
|
||||
}
|
||||
end
|
||||
|
||||
function Speed:on_global_mouse_move()
|
||||
if not self.dragging then return end
|
||||
|
||||
self.dragging.distance = cursor.x - self.dragging.start_x
|
||||
self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every)
|
||||
|
||||
local speed_current = state.speed
|
||||
local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance
|
||||
speed_drag_current = clamp(0.01, speed_drag_current, 100)
|
||||
local drag_dir_up = speed_drag_current > speed_current
|
||||
|
||||
local speed_step_next = speed_current
|
||||
local speed_drag_diff = math.abs(speed_drag_current - speed_current)
|
||||
while math.abs(speed_step_next - speed_current) < speed_drag_diff do
|
||||
speed_step_next = self:speed_step(speed_step_next, drag_dir_up)
|
||||
end
|
||||
local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up)
|
||||
|
||||
local speed_new = speed_step_prev
|
||||
local speed_next_diff = math.abs(speed_drag_current - speed_step_next)
|
||||
local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev)
|
||||
if speed_next_diff < speed_prev_diff then
|
||||
speed_new = speed_step_next
|
||||
end
|
||||
|
||||
if speed_new ~= speed_current then
|
||||
mp.set_property_native('speed', speed_new)
|
||||
end
|
||||
end
|
||||
|
||||
function Speed:on_mbtn_left_up()
|
||||
-- Reset speed on short clicks
|
||||
if self.dragging and math.abs(self.dragging.distance) < 6 and mp.get_time() - self.dragging.start_time < 0.15 then
|
||||
mp.set_property_native('speed', 1)
|
||||
end
|
||||
end
|
||||
|
||||
function Speed:on_global_mbtn_left_up()
|
||||
self.dragging = nil
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Speed:on_global_mouse_leave()
|
||||
self.dragging = nil
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Speed:on_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
|
||||
function Speed:on_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end
|
||||
|
||||
function Speed:render()
|
||||
local visibility = self:get_visibility()
|
||||
local opacity = self.dragging and 1 or visibility
|
||||
|
||||
if opacity <= 0 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Background
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {color = bg, radius = 2, opacity = opacity * options.speed_opacity})
|
||||
|
||||
-- Coordinates
|
||||
local ax, ay = self.ax, self.ay
|
||||
local bx, by = self.bx, ay + self.height
|
||||
local half_width = (self.width / 2)
|
||||
local half_x = ax + half_width
|
||||
|
||||
-- Notches
|
||||
local speed_at_center = state.speed
|
||||
if self.dragging then
|
||||
speed_at_center = self.dragging.start_speed + self.dragging.speed_distance
|
||||
speed_at_center = clamp(0.01, speed_at_center, 100)
|
||||
end
|
||||
local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every
|
||||
local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing)
|
||||
local guide_size = math.floor(self.height / 7.5)
|
||||
local notch_by = by - guide_size
|
||||
local notch_ay_big = ay + round(self.font_size * 1.1)
|
||||
local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
|
||||
local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
|
||||
local from_to_index = math.floor(self.notches / 2)
|
||||
|
||||
for i = -from_to_index, from_to_index do
|
||||
local notch_speed = nearest_notch_speed + (i * self.notch_every)
|
||||
|
||||
if notch_speed >= 0 and notch_speed <= 100 then
|
||||
local notch_x = nearest_notch_x + (i * self.notch_spacing)
|
||||
local notch_thickness = 1
|
||||
local notch_ay = notch_ay_small
|
||||
if (notch_speed % (self.notch_every * 10)) < 0.00000001 then
|
||||
notch_ay = notch_ay_big
|
||||
notch_thickness = 1.5
|
||||
elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then
|
||||
notch_ay = notch_ay_medium
|
||||
end
|
||||
|
||||
ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, {
|
||||
color = fg, border = 1, border_color = bg,
|
||||
opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Center guide
|
||||
ass:new_event()
|
||||
ass:append('{\\rDefault\\an7\\blur0\\bord1\\shad0\\1c&H' .. fg .. '\\3c&H' .. bg .. '}')
|
||||
ass:opacity(opacity)
|
||||
ass:pos(0, 0)
|
||||
ass:draw_start()
|
||||
ass:move_to(half_x, by - 2 - guide_size)
|
||||
ass:line_to(half_x + guide_size, by - 2)
|
||||
ass:line_to(half_x - guide_size, by - 2)
|
||||
ass:draw_stop()
|
||||
|
||||
-- Speed value
|
||||
local speed_text = (round(state.speed * 100) / 100) .. 'x'
|
||||
ass:txt(half_x, ay + (notch_ay_big - ay) / 2, 5, speed_text, {
|
||||
size = self.font_size, color = bgt, border = options.text_border, border_color = bg, opacity = opacity,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Speed
|
340
scripts/uosc/elements/Timeline.lua
Normal file
340
scripts/uosc/elements/Timeline.lua
Normal file
@@ -0,0 +1,340 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@class Timeline : Element
|
||||
local Timeline = class(Element)
|
||||
|
||||
function Timeline:new() return Class.new(self) --[[@as Timeline]] end
|
||||
function Timeline:init()
|
||||
Element.init(self, 'timeline')
|
||||
self.pressed = false
|
||||
self.obstructed = false
|
||||
self.size_max = 0
|
||||
self.size_min = 0
|
||||
self.size_min_override = options.timeline_start_hidden and 0 or nil
|
||||
self.font_size = 0
|
||||
self.top_border = options.timeline_border
|
||||
|
||||
-- Release any dragging when file gets unloaded
|
||||
mp.register_event('end-file', function() self.pressed = false end)
|
||||
end
|
||||
|
||||
function Timeline:get_visibility()
|
||||
return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self))
|
||||
or Element.get_visibility(self)
|
||||
end
|
||||
|
||||
function Timeline:decide_enabled()
|
||||
self.enabled = not self.obstructed and state.duration and state.duration > 0 and state.time
|
||||
end
|
||||
|
||||
function Timeline:get_effective_size_min()
|
||||
return self.size_min_override or self.size_min
|
||||
end
|
||||
|
||||
function Timeline:get_effective_size()
|
||||
if Elements.speed and Elements.speed.dragging then return self.size_max end
|
||||
local size_min = self:get_effective_size_min()
|
||||
return size_min + math.ceil((self.size_max - size_min) * self:get_visibility())
|
||||
end
|
||||
|
||||
function Timeline:get_effective_line_width()
|
||||
return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width
|
||||
end
|
||||
|
||||
function Timeline:update_dimensions()
|
||||
if state.fullormaxed then
|
||||
self.size_min = options.timeline_size_min_fullscreen
|
||||
self.size_max = options.timeline_size_max_fullscreen
|
||||
else
|
||||
self.size_min = options.timeline_size_min
|
||||
self.size_max = options.timeline_size_max
|
||||
end
|
||||
self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale)
|
||||
self.ax = Elements.window_border.size
|
||||
self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border
|
||||
self.bx = display.width - Elements.window_border.size
|
||||
self.by = display.height - Elements.window_border.size
|
||||
self.width = self.bx - self.ax
|
||||
|
||||
-- Disable if not enough space
|
||||
local available_space = display.height - Elements.window_border.size * 2
|
||||
if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end
|
||||
self.obstructed = available_space < self.size_max + 10
|
||||
self:decide_enabled()
|
||||
end
|
||||
|
||||
function Timeline:get_time_at_x(x)
|
||||
local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() - 1 or 0)
|
||||
local time_width = self.width - line_width - 1
|
||||
local fax = (time_width) * state.time / state.duration
|
||||
local fbx = fax + line_width
|
||||
-- time starts 0.5 pixels in
|
||||
x = x - self.ax - 0.5
|
||||
if x > fbx then x = x - line_width
|
||||
elseif x > fax then x = fax end
|
||||
local progress = clamp(0, x / time_width, 1)
|
||||
return state.duration * progress
|
||||
end
|
||||
|
||||
---@param fast? boolean
|
||||
function Timeline:set_from_cursor(fast)
|
||||
if state.time and state.duration then
|
||||
mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
|
||||
end
|
||||
end
|
||||
function Timeline:clear_thumbnail() mp.commandv('script-message-to', 'thumbfast', 'clear') end
|
||||
|
||||
function Timeline:on_mbtn_left_down()
|
||||
self.pressed = true
|
||||
self.pressed_pause = state.pause
|
||||
mp.set_property_native('pause', true)
|
||||
self:set_from_cursor()
|
||||
end
|
||||
function Timeline:on_prop_duration() self:decide_enabled() end
|
||||
function Timeline:on_prop_time() self:decide_enabled() end
|
||||
function Timeline:on_prop_border() self:update_dimensions() end
|
||||
function Timeline:on_prop_fullormaxed() self:update_dimensions() end
|
||||
function Timeline:on_display() self:update_dimensions() end
|
||||
function Timeline:on_mouse_leave() self:clear_thumbnail() end
|
||||
function Timeline:on_global_mbtn_left_up()
|
||||
if self.pressed then
|
||||
mp.set_property_native('pause', self.pressed_pause)
|
||||
self.pressed = false
|
||||
end
|
||||
self:clear_thumbnail()
|
||||
end
|
||||
function Timeline:on_global_mouse_leave()
|
||||
self.pressed = false
|
||||
self:clear_thumbnail()
|
||||
end
|
||||
|
||||
Timeline.seek_timer = mp.add_timeout(0.05, function() Elements.timeline:set_from_cursor() end)
|
||||
Timeline.seek_timer:kill()
|
||||
function Timeline:on_global_mouse_move()
|
||||
if self.pressed then
|
||||
if self.width / state.duration < 10 then
|
||||
self:set_from_cursor(true)
|
||||
self.seek_timer:kill()
|
||||
self.seek_timer:resume()
|
||||
else self:set_from_cursor() end
|
||||
end
|
||||
end
|
||||
function Timeline:on_wheel_up() mp.commandv('seek', options.timeline_step) end
|
||||
function Timeline:on_wheel_down() mp.commandv('seek', -options.timeline_step) end
|
||||
|
||||
function Timeline:render()
|
||||
if self.size_max == 0 then return end
|
||||
|
||||
local size_min = self:get_effective_size_min()
|
||||
local size = self:get_effective_size()
|
||||
local visibility = self:get_visibility()
|
||||
|
||||
if size < 1 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
|
||||
local hide_text_below = math.max(self.font_size * 0.8, size_min * 2)
|
||||
local hide_text_ramp = hide_text_below / 2
|
||||
local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
|
||||
|
||||
local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4)
|
||||
local progress = state.time / state.duration
|
||||
local is_line = options.timeline_style == 'line'
|
||||
|
||||
-- Foreground & Background bar coordinates
|
||||
local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
|
||||
local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
|
||||
local fcy = fay + (size / 2)
|
||||
|
||||
local line_width = 0
|
||||
|
||||
if is_line then
|
||||
local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1)
|
||||
local line_width_max = self:get_effective_line_width()
|
||||
local max_min_width_delta = size_min > 0
|
||||
and line_width_max - line_width_max * options.timeline_line_width_minimized_scale
|
||||
or 0
|
||||
line_width = line_width_max - (max_min_width_delta * minimized_fraction)
|
||||
fax = bax + (self.width - line_width) * progress
|
||||
fbx = fax + line_width
|
||||
line_width = line_width - 1
|
||||
else
|
||||
fax, fbx = bax, bax + self.width * progress
|
||||
end
|
||||
|
||||
local foreground_size = fby - fay
|
||||
local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
|
||||
|
||||
-- time starts 0.5 pixels in
|
||||
local time_ax = bax + 0.5
|
||||
local time_width = self.width - line_width - 1
|
||||
|
||||
-- time to x: calculates x coordinate so that it never lies inside of the line
|
||||
local function t2x(time)
|
||||
local x = time_ax + time_width * time / state.duration
|
||||
return time <= state.time and x or x + line_width
|
||||
end
|
||||
|
||||
-- Background
|
||||
ass:new_event()
|
||||
ass:pos(0, 0)
|
||||
ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
|
||||
ass:opacity(options.timeline_opacity)
|
||||
ass:draw_start()
|
||||
ass:rect_cw(bax, bay, fax, bby) --left of progress
|
||||
ass:rect_cw(fbx, bay, bbx, bby) --right of progress
|
||||
ass:rect_cw(fax, bay, fbx, fay) --above progress
|
||||
ass:draw_stop()
|
||||
|
||||
-- Progress
|
||||
ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity})
|
||||
|
||||
-- Uncached ranges
|
||||
local buffered_time = nil
|
||||
if state.uncached_ranges then
|
||||
local opts = {size = 80, anchor_y = fby}
|
||||
local texture_char = visibility > 0 and 'b' or 'a'
|
||||
local offset = opts.size / (visibility > 0 and 24 or 28)
|
||||
for _, range in ipairs(state.uncached_ranges) do
|
||||
if not buffered_time and (range[1] > state.time or range[2] > state.time) then
|
||||
buffered_time = range[1] - state.time
|
||||
end
|
||||
local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
|
||||
local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
|
||||
opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
|
||||
ass:texture(ax, fay, bx, fby, texture_char, opts)
|
||||
opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
|
||||
ass:texture(ax, fay, bx, fby, texture_char, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Custom ranges
|
||||
for _, chapter_range in ipairs(state.chapter_ranges) do
|
||||
local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
|
||||
local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
|
||||
or t2x(math.min(chapter_range['end'], state.duration))
|
||||
ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
|
||||
end
|
||||
|
||||
-- Chapters
|
||||
if (options.timeline_chapters_opacity > 0
|
||||
and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)
|
||||
) then
|
||||
local diamond_radius = foreground_size < 3 and foreground_size or math.max(foreground_size / 10, 3)
|
||||
local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
|
||||
|
||||
if diamond_radius > 0 then
|
||||
local function draw_chapter(time)
|
||||
local chapter_x = t2x(time)
|
||||
local chapter_y = fay - 1
|
||||
ass:new_event()
|
||||
ass:append(string.format(
|
||||
'{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
|
||||
diamond_border, fg, bg, bg, opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity)
|
||||
))
|
||||
ass:draw_start()
|
||||
ass:move_to(chapter_x - diamond_radius, chapter_y)
|
||||
ass:line_to(chapter_x, chapter_y - diamond_radius)
|
||||
ass:line_to(chapter_x + diamond_radius, chapter_y)
|
||||
ass:line_to(chapter_x, chapter_y + diamond_radius)
|
||||
ass:draw_stop()
|
||||
end
|
||||
|
||||
if state.chapters ~= nil then
|
||||
for i, chapter in ipairs(state.chapters) do
|
||||
draw_chapter(chapter.time)
|
||||
end
|
||||
end
|
||||
|
||||
if state.ab_loop_a and state.ab_loop_a > 0 then draw_chapter(state.ab_loop_a) end
|
||||
if state.ab_loop_b and state.ab_loop_b > 0 then draw_chapter(state.ab_loop_b) end
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_timeline_text(x, y, align, text, opts)
|
||||
opts.color, opts.border_color = fgt, fg
|
||||
opts.clip = '\\clip(' .. foreground_coordinates .. ')'
|
||||
ass:txt(x, y, align, text, opts)
|
||||
opts.color, opts.border_color = bgt, bg
|
||||
opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
|
||||
ass:txt(x, y, align, text, opts)
|
||||
end
|
||||
|
||||
-- Time values
|
||||
if text_opacity > 0 then
|
||||
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2}
|
||||
-- Upcoming cache time
|
||||
if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then
|
||||
local x, align = fbx + 5, 4
|
||||
local cache_opts = {size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = 1}
|
||||
local human = round(math.max(buffered_time, 0)) .. 's'
|
||||
local width = text_width(human, cache_opts)
|
||||
local time_width = text_width('00:00:00', time_opts)
|
||||
local min_x, max_x = bax + spacing + 5 + time_width, bbx - spacing - 5 - time_width
|
||||
if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
|
||||
draw_timeline_text(x, fcy, align, human, cache_opts)
|
||||
end
|
||||
|
||||
-- Elapsed time
|
||||
if state.time_human then
|
||||
draw_timeline_text(bax + spacing, fcy, 4, state.time_human, time_opts)
|
||||
end
|
||||
|
||||
-- End time
|
||||
if state.duration_or_remaining_time_human then
|
||||
draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, time_opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Hovered time and chapter
|
||||
if (self.proximity_raw == 0 or self.pressed) and not (Elements.speed and Elements.speed.dragging) then
|
||||
local hovered_seconds = self:get_time_at_x(cursor.x)
|
||||
|
||||
-- Cursor line
|
||||
-- 0.5 to switch when the pixel is half filled in
|
||||
local color = ((fax - 0.5) < cursor.x and cursor.x < (fbx + 0.5)) and bg or fg
|
||||
local ax, ay, bx, by = cursor.x - 0.5, fay, cursor.x + 0.5, fby
|
||||
ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2})
|
||||
local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by}
|
||||
|
||||
-- Timestamp
|
||||
local opts = {size = self.font_size, offset = 4}
|
||||
opts.width_overwrite = text_width('00:00:00', opts)
|
||||
ass:tooltip(tooltip_anchor, format_time(hovered_seconds), opts)
|
||||
tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - 4
|
||||
|
||||
-- Thumbnail
|
||||
if not thumbnail.disabled and thumbnail.width ~= 0 and thumbnail.height ~= 0 then
|
||||
local scale_x, scale_y = display.scale_x, display.scale_y
|
||||
local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y)
|
||||
local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y
|
||||
local thumb_width, thumb_height = thumbnail.width, thumbnail.height
|
||||
local thumb_x = round(clamp(
|
||||
thumb_x_margin, cursor.x * scale_x - thumb_width / 2,
|
||||
display.width * scale_x - thumb_width - thumb_x_margin
|
||||
))
|
||||
local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height)
|
||||
local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y
|
||||
local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y
|
||||
ass:rect(ax, ay, bx, by, {color = bg, border = 1, border_color = fg, border_opacity = 0.08, radius = 2})
|
||||
mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
|
||||
tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay
|
||||
end
|
||||
|
||||
-- Chapter title
|
||||
if #state.chapters > 0 then
|
||||
local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true)
|
||||
if chapter and not chapter.is_end_only then
|
||||
ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
|
||||
size = self.font_size, offset = 10, responsive = false, bold = true,
|
||||
width_overwrite = chapter.title_wrapped_width * self.font_size,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Timeline
|
182
scripts/uosc/elements/TopBar.lua
Normal file
182
scripts/uosc/elements/TopBar.lua
Normal file
@@ -0,0 +1,182 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
|
||||
|
||||
---@class TopBarButton : Element
|
||||
local TopBarButton = class(Element)
|
||||
|
||||
---@param id string
|
||||
---@param props TopBarButtonProps
|
||||
function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
|
||||
function TopBarButton:init(id, props)
|
||||
Element.init(self, id, props)
|
||||
self.anchor_id = 'top_bar'
|
||||
self.icon = props.icon
|
||||
self.background = props.background
|
||||
self.command = props.command
|
||||
end
|
||||
|
||||
function TopBarButton:on_mbtn_left_down()
|
||||
mp.command(type(self.command) == 'function' and self.command() or self.command)
|
||||
end
|
||||
|
||||
function TopBarButton:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Background on hover
|
||||
if self.proximity_raw == 0 then
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
|
||||
end
|
||||
|
||||
local width, height = self.bx - self.ax, self.by - self.ay
|
||||
local icon_size = math.min(width, height) * 0.5
|
||||
ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
|
||||
opacity = visibility, border = options.text_border,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
--[[ TopBar ]]
|
||||
|
||||
---@class TopBar : Element
|
||||
local TopBar = class(Element)
|
||||
|
||||
function TopBar:new() return Class.new(self) --[[@as TopBar]] end
|
||||
function TopBar:init()
|
||||
Element.init(self, 'top_bar')
|
||||
self.pressed = false
|
||||
self.size, self.size_max, self.size_min = 0, 0, 0
|
||||
self.icon_size, self.spacing, self.font_size, self.title_bx = 1, 1, 1, 1
|
||||
self.size_min_override = options.timeline_start_hidden and 0 or nil
|
||||
self.top_border = options.timeline_border
|
||||
|
||||
local function decide_maximized_command()
|
||||
return state.border
|
||||
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
||||
or 'set window-maximized no;cycle fullscreen'
|
||||
end
|
||||
|
||||
-- Order aligns from right to left
|
||||
self.buttons = {
|
||||
TopBarButton:new('tb_close', {icon = 'close', background = '2311e8', command = 'quit'}),
|
||||
TopBarButton:new('tb_max', {icon = 'crop_square', background = '222222', command = decide_maximized_command}),
|
||||
TopBarButton:new('tb_min', {icon = 'minimize', background = '222222', command = 'cycle window-minimized'}),
|
||||
}
|
||||
end
|
||||
|
||||
function TopBar:decide_enabled()
|
||||
if options.top_bar == 'no-border' then
|
||||
self.enabled = not state.border or state.fullscreen
|
||||
else
|
||||
self.enabled = options.top_bar == 'always'
|
||||
end
|
||||
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title)
|
||||
for _, element in ipairs(self.buttons) do
|
||||
element.enabled = self.enabled and options.top_bar_controls
|
||||
end
|
||||
end
|
||||
|
||||
function TopBar:update_dimensions()
|
||||
self.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size
|
||||
self.icon_size = round(self.size * 0.5)
|
||||
self.spacing = math.ceil(self.size * 0.25)
|
||||
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
|
||||
self.button_width = round(self.size * 1.15)
|
||||
self.ay = Elements.window_border.size
|
||||
self.bx = display.width - Elements.window_border.size
|
||||
self.by = self.size + Elements.window_border.size
|
||||
self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
|
||||
self.ax = options.top_bar_title and Elements.window_border.size or self.title_bx
|
||||
|
||||
local button_bx = self.bx
|
||||
for _, element in pairs(self.buttons) do
|
||||
element.ax, element.bx = button_bx - self.button_width, button_bx
|
||||
element.ay, element.by = self.ay, self.by
|
||||
button_bx = button_bx - self.button_width
|
||||
end
|
||||
end
|
||||
|
||||
function TopBar:on_prop_border()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:on_prop_fullscreen()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:on_prop_maximized()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:on_display() self:update_dimensions() end
|
||||
|
||||
function TopBar:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Window title
|
||||
if options.top_bar_title and (state.title or state.has_playlist) then
|
||||
local bg_margin = math.floor((self.size - self.font_size) / 4)
|
||||
local padding = self.font_size / 2
|
||||
local title_ax = self.ax + bg_margin
|
||||
local title_ay = self.ay + bg_margin
|
||||
local max_bx = self.title_bx - self.spacing
|
||||
|
||||
-- Playlist position
|
||||
if state.has_playlist then
|
||||
local text = state.playlist_pos .. '' .. state.playlist_count
|
||||
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
|
||||
.. state.playlist_count
|
||||
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
|
||||
local bx = round(title_ax + text_width(text, opts) + padding * 2)
|
||||
ass:rect(title_ax, title_ay, bx, self.by - bg_margin, {color = fg, opacity = visibility, radius = 2})
|
||||
ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, opts)
|
||||
title_ax = bx + bg_margin
|
||||
end
|
||||
|
||||
-- Title
|
||||
local text = state.title
|
||||
if max_bx - title_ax > self.font_size * 3 and text and text ~= '' then
|
||||
local opts = {
|
||||
size = self.font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility,
|
||||
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
|
||||
}
|
||||
local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
|
||||
local by = self.by - bg_margin
|
||||
ass:rect(title_ax, title_ay, bx, by, {
|
||||
color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
|
||||
})
|
||||
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, opts)
|
||||
title_ay = by + 1
|
||||
end
|
||||
|
||||
-- Subtitle: current chapter
|
||||
if state.current_chapter and max_bx - title_ax > self.font_size * 3 then
|
||||
local font_size = self.font_size * 0.8
|
||||
local height = font_size * 1.5
|
||||
local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
|
||||
local by = title_ay + height
|
||||
local opts = {
|
||||
size = font_size, italic = true, wrap = 2, color = bgt,
|
||||
border = 1, border_color = bg, opacity = visibility * 0.8,
|
||||
}
|
||||
local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
|
||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
|
||||
ass:rect(title_ax, title_ay, bx, by, {
|
||||
color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
|
||||
})
|
||||
ass:txt(title_ax + padding, title_ay + height / 2, 4, text, opts)
|
||||
end
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return TopBar
|
240
scripts/uosc/elements/Volume.lua
Normal file
240
scripts/uosc/elements/Volume.lua
Normal file
@@ -0,0 +1,240 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
--[[ MuteButton ]]
|
||||
|
||||
---@class MuteButton : Element
|
||||
local MuteButton = class(Element)
|
||||
---@param props? ElementProps
|
||||
function MuteButton:new(props) return Class.new(self, 'volume_mute', props) --[[@as MuteButton]] end
|
||||
function MuteButton:on_mbtn_left_down() mp.commandv('cycle', 'mute') end
|
||||
function MuteButton:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
local icon_name = state.mute and 'volume_off' or 'volume_up'
|
||||
local width = self.bx - self.ax
|
||||
ass:icon(self.ax + (width / 2), self.by, width * 0.7, icon_name,
|
||||
{border = options.text_border, opacity = options.volume_opacity * visibility, align = 2}
|
||||
)
|
||||
return ass
|
||||
end
|
||||
|
||||
--[[ VolumeSlider ]]
|
||||
|
||||
---@class VolumeSlider : Element
|
||||
local VolumeSlider = class(Element)
|
||||
---@param props? ElementProps
|
||||
function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end
|
||||
function VolumeSlider:init(props)
|
||||
Element.init(self, 'volume_slider', props)
|
||||
self.pressed = false
|
||||
self.nudge_y = 0 -- vertical position where volume overflows 100
|
||||
self.nudge_size = 0
|
||||
self.draw_nudge = false
|
||||
self.spacing = 0
|
||||
self.radius = 1
|
||||
end
|
||||
|
||||
function VolumeSlider:set_volume(volume)
|
||||
volume = round(volume / options.volume_step) * options.volume_step
|
||||
if state.volume == volume then return end
|
||||
mp.commandv('set', 'volume', clamp(0, volume, state.volume_max))
|
||||
end
|
||||
|
||||
function VolumeSlider:set_from_cursor()
|
||||
local volume_fraction = (self.by - cursor.y - options.volume_border) / (self.by - self.ay - options.volume_border)
|
||||
self:set_volume(volume_fraction * state.volume_max)
|
||||
end
|
||||
|
||||
function VolumeSlider:on_coordinates()
|
||||
if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end
|
||||
local width = self.bx - self.ax
|
||||
self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max))
|
||||
self.nudge_size = round(width * 0.18)
|
||||
self.draw_nudge = self.ay < self.nudge_y
|
||||
self.spacing = round(width * 0.2)
|
||||
self.radius = math.max(2, (self.bx - self.ax) / 10)
|
||||
end
|
||||
function VolumeSlider:on_mbtn_left_down()
|
||||
self.pressed = true
|
||||
self:set_from_cursor()
|
||||
end
|
||||
function VolumeSlider:on_global_mbtn_left_up() self.pressed = false end
|
||||
function VolumeSlider:on_global_mouse_leave() self.pressed = false end
|
||||
function VolumeSlider:on_global_mouse_move()
|
||||
if self.pressed then self:set_from_cursor() end
|
||||
end
|
||||
function VolumeSlider:on_wheel_up() self:set_volume(state.volume + options.volume_step) end
|
||||
function VolumeSlider:on_wheel_down() self:set_volume(state.volume - options.volume_step) end
|
||||
|
||||
function VolumeSlider:render()
|
||||
local visibility = self:get_visibility()
|
||||
local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by
|
||||
local width, height = bx - ax, by - ay
|
||||
|
||||
if width <= 0 or height <= 0 or visibility <= 0 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -infinity, self.nudge_size
|
||||
local volume_y = self.ay + options.volume_border +
|
||||
((height - (options.volume_border * 2)) * (1 - math.min(state.volume / state.volume_max, 1)))
|
||||
|
||||
-- Draws a rectangle with nudge at requested position
|
||||
---@param p number Padding from slider edges.
|
||||
---@param cy? number A y coordinate where to clip the path from the bottom.
|
||||
function create_nudged_path(p, cy)
|
||||
cy = cy or ay + p
|
||||
local ax, bx, by = ax + p, bx - p, by - p
|
||||
local r = math.max(1, self.radius - p)
|
||||
local d, rh = r * 2, r / 2
|
||||
local nudge_size = ((quarter_pi_sin * (nudge_size - p)) + p) / quarter_pi_sin
|
||||
local path = assdraw.ass_new()
|
||||
path:move_to(bx - r, by)
|
||||
path:line_to(ax + r, by)
|
||||
if cy > by - d then
|
||||
local subtracted_radius = (d - (cy - (by - d))) / 2
|
||||
local xbd = (r - subtracted_radius * 1.35) -- x bezier delta
|
||||
path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy)
|
||||
path:line_to(bx - r, cy)
|
||||
path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by)
|
||||
else
|
||||
path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r)
|
||||
local nudge_bottom_y = nudge_y + nudge_size
|
||||
|
||||
if cy + rh <= nudge_bottom_y then
|
||||
path:line_to(ax, nudge_bottom_y)
|
||||
if cy <= nudge_y then
|
||||
path:line_to((ax + nudge_size), nudge_y)
|
||||
local nudge_top_y = nudge_y - nudge_size
|
||||
if cy <= nudge_top_y then
|
||||
local r, rh = r, rh
|
||||
if cy > nudge_top_y - r then
|
||||
r = nudge_top_y - cy
|
||||
rh = r / 2
|
||||
end
|
||||
path:line_to(ax, nudge_top_y)
|
||||
path:line_to(ax, cy + r)
|
||||
path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
|
||||
path:line_to(bx - r, cy)
|
||||
path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
|
||||
path:line_to(bx, nudge_top_y)
|
||||
else
|
||||
local triangle_side = cy - nudge_top_y
|
||||
path:line_to((ax + triangle_side), cy)
|
||||
path:line_to((bx - triangle_side), cy)
|
||||
end
|
||||
path:line_to((bx - nudge_size), nudge_y)
|
||||
else
|
||||
local triangle_side = nudge_bottom_y - cy
|
||||
path:line_to((ax + triangle_side), cy)
|
||||
path:line_to((bx - triangle_side), cy)
|
||||
end
|
||||
path:line_to(bx, nudge_bottom_y)
|
||||
else
|
||||
path:line_to(ax, cy + r)
|
||||
path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
|
||||
path:line_to(bx - r, cy)
|
||||
path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
|
||||
end
|
||||
path:line_to(bx, by - r)
|
||||
path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
-- BG & FG paths
|
||||
local bg_path = create_nudged_path(0)
|
||||
local fg_path = create_nudged_path(options.volume_border, volume_y)
|
||||
|
||||
-- Background
|
||||
ass:new_event()
|
||||
ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg ..
|
||||
'\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}')
|
||||
ass:opacity(options.volume_opacity, visibility)
|
||||
ass:pos(0, 0)
|
||||
ass:draw_start()
|
||||
ass:append(bg_path.text)
|
||||
ass:draw_stop()
|
||||
|
||||
-- Foreground
|
||||
ass:new_event()
|
||||
ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. fg .. '}')
|
||||
ass:opacity(options.volume_opacity, visibility)
|
||||
ass:pos(0, 0)
|
||||
ass:draw_start()
|
||||
ass:append(fg_path.text)
|
||||
ass:draw_stop()
|
||||
|
||||
-- Current volume value
|
||||
local volume_string = tostring(round(state.volume * 10) / 10)
|
||||
local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale)
|
||||
if volume_y < self.by - self.spacing then
|
||||
ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
|
||||
size = font_size, color = fgt, opacity = visibility,
|
||||
clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
|
||||
})
|
||||
end
|
||||
if volume_y > self.by - self.spacing - font_size then
|
||||
ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
|
||||
size = font_size, color = bgt, opacity = visibility,
|
||||
clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
|
||||
})
|
||||
end
|
||||
|
||||
-- Disabled stripes for no audio
|
||||
if not state.has_audio then
|
||||
local fg_100_path = create_nudged_path(options.volume_border)
|
||||
local texture_opts = {
|
||||
size = 200, color = 'ffffff', opacity = visibility * 0.1, anchor_x = ax,
|
||||
clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')',
|
||||
}
|
||||
ass:texture(ax, ay, bx, by, 'a', texture_opts)
|
||||
texture_opts.color = '000000'
|
||||
texture_opts.anchor_x = ax + texture_opts.size / 28
|
||||
ass:texture(ax, ay, bx, by, 'a', texture_opts)
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
--[[ Volume ]]
|
||||
|
||||
---@class Volume : Element
|
||||
local Volume = class(Element)
|
||||
|
||||
function Volume:new() return Class.new(self) --[[@as Volume]] end
|
||||
function Volume:init()
|
||||
Element.init(self, 'volume')
|
||||
self.mute = MuteButton:new({anchor_id = 'volume'})
|
||||
self.slider = VolumeSlider:new({anchor_id = 'volume'})
|
||||
end
|
||||
|
||||
function Volume:get_visibility()
|
||||
return self.slider.pressed and 1 or Elements.timeline.proximity_raw == 0 and -1 or Element.get_visibility(self)
|
||||
end
|
||||
|
||||
function Volume:update_dimensions()
|
||||
local width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size
|
||||
local controls, timeline, top_bar = Elements.controls, Elements.timeline, Elements.top_bar
|
||||
local min_y = top_bar.enabled and top_bar.by or 0
|
||||
local max_y = (controls and controls.enabled and controls.ay) or (timeline.enabled and timeline.ay)
|
||||
or display.height - top_bar.size
|
||||
local available_height = max_y - min_y
|
||||
local max_height = available_height * 0.8
|
||||
local height = round(math.min(width * 8, max_height))
|
||||
self.enabled = height > width * 2 -- don't render if too small
|
||||
local margin = (width / 2) + Elements.window_border.size
|
||||
self.ax = round(options.volume == 'left' and margin or display.width - margin - width)
|
||||
self.ay = min_y + round((available_height - height) / 2)
|
||||
self.bx = round(self.ax + width)
|
||||
self.by = round(self.ay + height)
|
||||
self.mute.enabled, self.slider.enabled = self.enabled, self.enabled
|
||||
self.mute:set_coordinates(self.ax, self.by - round(width * 0.8), self.bx, self.by)
|
||||
self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute.ay)
|
||||
end
|
||||
|
||||
function Volume:on_display() self:update_dimensions() end
|
||||
function Volume:on_prop_border() self:update_dimensions() end
|
||||
function Volume:on_controls_reflow() self:update_dimensions() end
|
||||
|
||||
return Volume
|
33
scripts/uosc/elements/WindowBorder.lua
Normal file
33
scripts/uosc/elements/WindowBorder.lua
Normal file
@@ -0,0 +1,33 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@class WindowBorder : Element
|
||||
local WindowBorder = class(Element)
|
||||
|
||||
function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end
|
||||
function WindowBorder:init()
|
||||
Element.init(self, 'window_border')
|
||||
self.ignores_menu = true
|
||||
self.size = 0
|
||||
end
|
||||
|
||||
function WindowBorder:decide_enabled()
|
||||
self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border
|
||||
self.size = self.enabled and options.window_border_size or 0
|
||||
end
|
||||
|
||||
function WindowBorder:on_prop_border() self:decide_enabled() end
|
||||
function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end
|
||||
|
||||
function WindowBorder:render()
|
||||
if self.size > 0 then
|
||||
local ass = assdraw.ass_new()
|
||||
local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
|
||||
(display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
|
||||
ass:rect(0, 0, display.width + 1, display.height + 1, {
|
||||
color = bg, clip = clip, opacity = options.window_border_opacity,
|
||||
})
|
||||
return ass
|
||||
end
|
||||
end
|
||||
|
||||
return WindowBorder
|
157
scripts/uosc/lib/ass.lua
Normal file
157
scripts/uosc/lib/ass.lua
Normal file
@@ -0,0 +1,157 @@
|
||||
--[[ ASSDRAW EXTENSIONS ]]
|
||||
|
||||
local ass_mt = getmetatable(assdraw.ass_new())
|
||||
|
||||
-- Opacity
|
||||
---@param opacity number|number[] Opacity of all elements, or an array of [primary, secondary, border, shadow] opacities.
|
||||
---@param fraction? number Optionally adjust the above opacity by this fraction.
|
||||
function ass_mt:opacity(opacity, fraction)
|
||||
fraction = fraction ~= nil and fraction or 1
|
||||
if type(opacity) == 'number' then
|
||||
self.text = self.text .. string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction))
|
||||
else
|
||||
self.text = self.text .. string.format(
|
||||
'{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}',
|
||||
opacity_to_alpha((opacity[1] or 0) * fraction),
|
||||
opacity_to_alpha((opacity[2] or 0) * fraction),
|
||||
opacity_to_alpha((opacity[3] or 0) * fraction),
|
||||
opacity_to_alpha((opacity[4] or 0) * fraction)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Icon
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param size number
|
||||
---@param name string
|
||||
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number}
|
||||
function ass_mt:icon(x, y, size, name, opts)
|
||||
opts = opts or {}
|
||||
opts.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false
|
||||
self:txt(x, y, opts.align or 5, name, opts)
|
||||
end
|
||||
|
||||
-- Text
|
||||
-- Named `txt` because `ass.text` is a value.
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param align number
|
||||
---@param value string|number
|
||||
---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; rotate?: number; wrap?: number; opacity?: number; clip?: string}
|
||||
function ass_mt:txt(x, y, align, value, opts)
|
||||
local border_size = opts.border or 0
|
||||
local shadow_size = opts.shadow or 0
|
||||
local tags = '\\pos(' .. x .. ',' .. y .. ')\\rDefault\\an' .. align .. '\\blur0'
|
||||
-- font
|
||||
tags = tags .. '\\fn' .. (opts.font or config.font)
|
||||
-- font size
|
||||
tags = tags .. '\\fs' .. opts.size
|
||||
-- bold
|
||||
if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end
|
||||
-- italic
|
||||
if opts.italic then tags = tags .. '\\i1' end
|
||||
-- rotate
|
||||
if opts.rotate then tags = tags .. '\\frz' .. opts.rotate end
|
||||
-- wrap
|
||||
if opts.wrap then tags = tags .. '\\q' .. opts.wrap end
|
||||
-- border
|
||||
tags = tags .. '\\bord' .. border_size
|
||||
-- shadow
|
||||
tags = tags .. '\\shad' .. shadow_size
|
||||
-- colors
|
||||
tags = tags .. '\\1c&H' .. (opts.color or bgt)
|
||||
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
|
||||
if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) end
|
||||
-- opacity
|
||||
if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
|
||||
-- clip
|
||||
if opts.clip then tags = tags .. opts.clip end
|
||||
-- render
|
||||
self:new_event()
|
||||
self.text = self.text .. '{' .. tags .. '}' .. value
|
||||
end
|
||||
|
||||
-- Tooltip
|
||||
---@param element {ax: number; ay: number; bx: number; by: number}
|
||||
---@param value string|number
|
||||
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, responsive?: boolean}
|
||||
function ass_mt:tooltip(element, value, opts)
|
||||
opts = opts or {}
|
||||
opts.size = opts.size or 16
|
||||
opts.border = options.text_border
|
||||
opts.border_color = bg
|
||||
local offset = opts.offset or opts.size / 2
|
||||
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
|
||||
local x = element.ax + (element.bx - element.ax) / 2
|
||||
local y = align_top and element.ay - offset or element.by + offset
|
||||
local margin = (opts.width_overwrite or text_width(value, opts)) / 2 + 10
|
||||
self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts)
|
||||
end
|
||||
|
||||
-- Rectangle
|
||||
---@param ax number
|
||||
---@param ay number
|
||||
---@param bx number
|
||||
---@param by number
|
||||
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; border_opacity?: number; clip?: string, radius?: number}
|
||||
function ass_mt:rect(ax, ay, bx, by, opts)
|
||||
opts = opts or {}
|
||||
local border_size = opts.border or 0
|
||||
local tags = '\\pos(0,0)\\rDefault\\an7\\blur0'
|
||||
-- border
|
||||
tags = tags .. '\\bord' .. border_size
|
||||
-- colors
|
||||
tags = tags .. '\\1c&H' .. (opts.color or fg)
|
||||
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
|
||||
-- opacity
|
||||
if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
|
||||
if opts.border_opacity then tags = tags .. string.format('\\3a&H%X&', opacity_to_alpha(opts.border_opacity)) end
|
||||
-- clip
|
||||
if opts.clip then
|
||||
tags = tags .. opts.clip
|
||||
end
|
||||
-- draw
|
||||
self:new_event()
|
||||
self.text = self.text .. '{' .. tags .. '}'
|
||||
self:draw_start()
|
||||
if opts.radius then
|
||||
self:round_rect_cw(ax, ay, bx, by, opts.radius)
|
||||
else
|
||||
self:rect_cw(ax, ay, bx, by)
|
||||
end
|
||||
self:draw_stop()
|
||||
end
|
||||
|
||||
-- Circle
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param radius number
|
||||
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string}
|
||||
function ass_mt:circle(x, y, radius, opts)
|
||||
opts = opts or {}
|
||||
opts.radius = radius
|
||||
self:rect(x - radius, y - radius, x + radius, y + radius, opts)
|
||||
end
|
||||
|
||||
-- Texture
|
||||
---@param ax number
|
||||
---@param ay number
|
||||
---@param bx number
|
||||
---@param by number
|
||||
---@param char string Texture font character.
|
||||
---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number}
|
||||
function ass_mt:texture(ax, ay, bx, by, char, opts)
|
||||
opts = opts or {}
|
||||
local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay
|
||||
local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')')
|
||||
local tile_size, opacity = opts.size or 100, opts.opacity or 0.2
|
||||
local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size
|
||||
local width, height = bx - x, by - y
|
||||
local line = string.rep(char, math.ceil((width / tile_size)))
|
||||
local lines = ''
|
||||
for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end
|
||||
self:txt(
|
||||
x, y, 7, lines,
|
||||
{font = 'uosc_textures', size = tile_size, color = opts.color, bold = false, opacity = opacity, clip = clip})
|
||||
end
|
280
scripts/uosc/lib/menus.lua
Normal file
280
scripts/uosc/lib/menus.lua
Normal file
@@ -0,0 +1,280 @@
|
||||
---@param data MenuData
|
||||
---@param opts? {submenu?: string; mouse_nav?: boolean}
|
||||
function open_command_menu(data, opts)
|
||||
local menu = Menu:open(data, function(value)
|
||||
if type(value) == 'string' then
|
||||
mp.command(value)
|
||||
else
|
||||
---@diagnostic disable-next-line: deprecated
|
||||
mp.commandv(unpack(value))
|
||||
end
|
||||
end, opts)
|
||||
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
|
||||
return menu
|
||||
end
|
||||
|
||||
---@param opts? {submenu?: string; mouse_nav?: boolean}
|
||||
function toggle_menu_with_items(opts)
|
||||
if Menu:is_open('menu') then Menu:close()
|
||||
else open_command_menu({type = 'menu', items = config.menu_items}, opts) end
|
||||
end
|
||||
|
||||
---@param options {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any)}
|
||||
function create_self_updating_menu_opener(options)
|
||||
return function()
|
||||
if Menu:is_open(options.type) then Menu:close() return end
|
||||
local list = mp.get_property_native(options.list_prop)
|
||||
local active = options.active_prop and mp.get_property_native(options.active_prop) or nil
|
||||
local menu
|
||||
|
||||
local function update() menu:update_items(options.serializer(list, active)) end
|
||||
|
||||
local ignore_initial_list = true
|
||||
local function handle_list_prop_change(name, value)
|
||||
if ignore_initial_list then ignore_initial_list = false
|
||||
else list = value update() end
|
||||
end
|
||||
|
||||
local ignore_initial_active = true
|
||||
local function handle_active_prop_change(name, value)
|
||||
if ignore_initial_active then ignore_initial_active = false
|
||||
else active = value update() end
|
||||
end
|
||||
|
||||
local initial_items, selected_index = options.serializer(list, active)
|
||||
|
||||
-- Items and active_index are set in the handle_prop_change callback, since adding
|
||||
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
||||
menu = Menu:open(
|
||||
{type = options.type, title = options.title, items = initial_items, selected_index = selected_index},
|
||||
options.on_select, {
|
||||
on_open = function()
|
||||
mp.observe_property(options.list_prop, 'native', handle_list_prop_change)
|
||||
if options.active_prop then
|
||||
mp.observe_property(options.active_prop, 'native', handle_active_prop_change)
|
||||
end
|
||||
end,
|
||||
on_close = function()
|
||||
mp.unobserve_property(handle_list_prop_change)
|
||||
mp.unobserve_property(handle_active_prop_change)
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command)
|
||||
local function serialize_tracklist(tracklist)
|
||||
local items = {}
|
||||
|
||||
if load_command then
|
||||
items[#items + 1] = {
|
||||
title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true,
|
||||
}
|
||||
end
|
||||
|
||||
local first_item_index = #items + 1
|
||||
local active_index = nil
|
||||
local disabled_item = nil
|
||||
|
||||
-- Add option to disable a subtitle track. This works for all tracks,
|
||||
-- but why would anyone want to disable audio or video? Better to not
|
||||
-- let people mistakenly select what is unwanted 99.999% of the time.
|
||||
-- If I'm mistaken and there is an active need for this, feel free to
|
||||
-- open an issue.
|
||||
if track_type == 'sub' then
|
||||
disabled_item = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true}
|
||||
items[#items + 1] = disabled_item
|
||||
end
|
||||
|
||||
for _, track in ipairs(tracklist) do
|
||||
if track.type == track_type then
|
||||
local hint_values = {}
|
||||
local function h(value) hint_values[#hint_values + 1] = value end
|
||||
|
||||
if track.lang then h(track.lang:upper()) end
|
||||
if track['demux-h'] then
|
||||
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
||||
end
|
||||
if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
|
||||
h(track.codec)
|
||||
if track['audio-channels'] then h(track['audio-channels'] .. ' channels') end
|
||||
if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
|
||||
if track.forced then h('forced') end
|
||||
if track.default then h('default') end
|
||||
if track.external then h('external') end
|
||||
|
||||
items[#items + 1] = {
|
||||
title = (track.title and track.title or 'Track ' .. track.id),
|
||||
hint = table.concat(hint_values, ', '),
|
||||
value = track.id,
|
||||
active = track.selected,
|
||||
}
|
||||
|
||||
if track.selected then
|
||||
if disabled_item then disabled_item.active = false end
|
||||
active_index = #items
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return items, active_index or first_item_index
|
||||
end
|
||||
|
||||
local function selection_handler(value)
|
||||
if value == '{load}' then
|
||||
mp.command(load_command)
|
||||
else
|
||||
mp.commandv('set', track_prop, value and value or 'no')
|
||||
|
||||
-- If subtitle track was selected, assume user also wants to see it
|
||||
if value and track_type == 'sub' then
|
||||
mp.commandv('set', 'sub-visibility', 'yes')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return create_self_updating_menu_opener({
|
||||
title = menu_title,
|
||||
type = track_type,
|
||||
list_prop = 'track-list',
|
||||
serializer = serialize_tracklist,
|
||||
on_select = selection_handler,
|
||||
})
|
||||
end
|
||||
|
||||
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
|
||||
|
||||
-- Opens a file navigation menu with items inside `directory_path`.
|
||||
---@param directory_path string
|
||||
---@param handle_select fun(path: string): nil
|
||||
---@param opts NavigationMenuOptions
|
||||
function open_file_navigation_menu(directory_path, handle_select, opts)
|
||||
directory = serialize_path(directory_path)
|
||||
opts = opts or {}
|
||||
|
||||
if not directory then
|
||||
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
|
||||
return
|
||||
end
|
||||
|
||||
local directories, dirs_error = utils.readdir(directory.path, 'dirs')
|
||||
local files, files_error = get_files_in_directory(directory.path, opts.allowed_types)
|
||||
local is_root = not directory.dirname
|
||||
|
||||
if not files or not directories then
|
||||
msg.error('Retrieving files from ' .. directory .. ' failed: ' .. (dirs_error or files_error or ''))
|
||||
return
|
||||
end
|
||||
|
||||
-- Files are already sorted
|
||||
table.sort(directories, file_order_comparator)
|
||||
|
||||
-- Pre-populate items with parent directory selector if not at root
|
||||
-- Each item value is a serialized path table it points to.
|
||||
local items = {}
|
||||
|
||||
if is_root then
|
||||
if state.os == 'windows' then
|
||||
items[#items + 1] = {
|
||||
title = '..', hint = 'Drives', value = {is_drives = true, is_to_parent = true}, separator = true,
|
||||
}
|
||||
end
|
||||
else
|
||||
local serialized = serialize_path(directory.dirname)
|
||||
serialized.is_directory = true
|
||||
serialized.is_to_parent = true
|
||||
items[#items + 1] = {title = '..', hint = 'parent dir', value = serialized, separator = true}
|
||||
end
|
||||
|
||||
local items_start_index = #items + 1
|
||||
|
||||
for _, dir in ipairs(directories) do
|
||||
local serialized = serialize_path(utils.join_path(directory.path, dir))
|
||||
if serialized then
|
||||
serialized.is_directory = true
|
||||
items[#items + 1] = {title = serialized.basename, value = serialized, hint = '/'}
|
||||
end
|
||||
end
|
||||
|
||||
for _, file in ipairs(files) do
|
||||
local serialized = serialize_path(utils.join_path(directory.path, file))
|
||||
if serialized then
|
||||
serialized.is_file = true
|
||||
items[#items + 1] = {title = serialized.basename, value = serialized}
|
||||
end
|
||||
end
|
||||
|
||||
for index, item in ipairs(items) do
|
||||
if not item.value.is_to_parent then
|
||||
if index == items_start_index then item.selected = true end
|
||||
|
||||
if opts.active_path == item.value.path then
|
||||
item.active = true
|
||||
if not opts.selected_path then item.selected = true end
|
||||
end
|
||||
|
||||
if opts.selected_path == item.value.path then item.selected = true end
|
||||
end
|
||||
end
|
||||
|
||||
local menu_data = {
|
||||
type = opts.type, title = opts.title or directory.basename .. '/', items = items,
|
||||
on_open = opts.on_open, on_close = opts.on_close,
|
||||
}
|
||||
|
||||
return Menu:open(menu_data, function(path)
|
||||
local inheritable_options = {
|
||||
type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
|
||||
}
|
||||
|
||||
if path.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})
|
||||
return
|
||||
end
|
||||
|
||||
if path.is_directory then
|
||||
-- Preselect directory we are coming from
|
||||
if path.is_to_parent then
|
||||
inheritable_options.selected_path = directory.path
|
||||
end
|
||||
|
||||
open_file_navigation_menu(path.path, handle_select, inheritable_options)
|
||||
else
|
||||
handle_select(path.path)
|
||||
end
|
||||
end)
|
||||
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 = {}
|
||||
|
||||
if process.status == 0 then
|
||||
for _, value in ipairs(split(process.stdout, '\n')) do
|
||||
local drive = string.match(value, 'Name=([A-Z]:)')
|
||||
if drive then
|
||||
local drive_path = normalize_path(drive)
|
||||
items[#items + 1] = {
|
||||
title = drive, hint = 'Drive', value = drive_path,
|
||||
selected = opts.selected_path == drive_path,
|
||||
active = opts.active_path == drive_path,
|
||||
}
|
||||
end
|
||||
end
|
||||
else
|
||||
msg.error(process.stderr)
|
||||
end
|
||||
|
||||
return Menu:open({type = opts.type, title = opts.title or 'Drives', items = items}, handle_select)
|
||||
end
|
152
scripts/uosc/lib/std.lua
Normal file
152
scripts/uosc/lib/std.lua
Normal file
@@ -0,0 +1,152 @@
|
||||
--[[ 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
|
||||
|
||||
---@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
|
||||
|
||||
---@param itable table
|
||||
---@param value any
|
||||
---@return integer|nil
|
||||
function itable_index_of(itable, value)
|
||||
for index, item in ipairs(itable) do
|
||||
if item == value then return index end
|
||||
end
|
||||
end
|
||||
|
||||
---@param itable table
|
||||
---@param compare fun(value: any, index: number)
|
||||
---@param from_end? boolean Search from the end of the table.
|
||||
---@return number|nil index
|
||||
---@return any|nil value
|
||||
function itable_find(itable, compare, from_end)
|
||||
local from, to, step = from_end and #itable or 1, from_end and 1 or #itable, from_end and -1 or 1
|
||||
for index = from, to, step do
|
||||
if compare(itable[index], index) then return index, itable[index] end
|
||||
end
|
||||
end
|
||||
|
||||
---@param itable table
|
||||
---@param decider fun(value: any, index: number)
|
||||
function itable_filter(itable, decider)
|
||||
local filtered = {}
|
||||
for index, value in ipairs(itable) do
|
||||
if decider(value, index) then filtered[#filtered + 1] = value end
|
||||
end
|
||||
return filtered
|
||||
end
|
||||
|
||||
---@param itable table
|
||||
---@param value any
|
||||
function itable_remove(itable, value)
|
||||
return itable_filter(itable, function(item) return item ~= value end)
|
||||
end
|
||||
|
||||
---@param itable table
|
||||
---@param start_pos? integer
|
||||
---@param end_pos? integer
|
||||
function itable_slice(itable, start_pos, end_pos)
|
||||
start_pos = start_pos and start_pos or 1
|
||||
end_pos = end_pos and end_pos or #itable
|
||||
|
||||
if end_pos < 0 then end_pos = #itable + end_pos + 1 end
|
||||
if start_pos < 0 then start_pos = #itable + start_pos + 1 end
|
||||
|
||||
local new_table = {}
|
||||
for index, value in ipairs(itable) do
|
||||
if index >= start_pos and index <= end_pos then
|
||||
new_table[#new_table + 1] = value
|
||||
end
|
||||
end
|
||||
return new_table
|
||||
end
|
||||
|
||||
---@generic T
|
||||
---@param a T[]|nil
|
||||
---@param b T[]|nil
|
||||
---@return T[]
|
||||
function itable_join(a, b)
|
||||
local result = {}
|
||||
if a then for _, value in ipairs(a) do result[#result + 1] = value end end
|
||||
if b then for _, value in ipairs(b) do result[#result + 1] = value end end
|
||||
return result
|
||||
end
|
||||
|
||||
---@param target any[]
|
||||
---@param source any[]
|
||||
function itable_append(target, source)
|
||||
for _, value in ipairs(source) do target[#target + 1] = value end
|
||||
return target
|
||||
end
|
||||
|
||||
---@param target any[]
|
||||
---@param source any[]
|
||||
---@param props? string[]
|
||||
function table_assign(target, source, props)
|
||||
if props then
|
||||
for _, name in ipairs(props) do target[name] = source[name] end
|
||||
else
|
||||
for prop, value in pairs(source) do target[prop] = value end
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
---@generic T
|
||||
---@param table T
|
||||
---@return T
|
||||
function table_shallow_copy(table)
|
||||
local result = {}
|
||||
for key, value in pairs(table) do result[key] = value end
|
||||
return result
|
||||
end
|
||||
|
||||
--[[ EASING FUNCTIONS ]]
|
||||
|
||||
function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
|
||||
function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end
|
||||
|
||||
--[[ CLASSES ]]
|
||||
|
||||
---@class Class
|
||||
Class = {}
|
||||
function Class:new(...)
|
||||
local object = setmetatable({}, {__index = self})
|
||||
object:init(...)
|
||||
return object
|
||||
end
|
||||
function Class:init() end
|
||||
function Class:destroy() end
|
||||
|
||||
function class(parent) return setmetatable({}, {__index = parent or Class}) end
|
414
scripts/uosc/lib/text.lua
Normal file
414
scripts/uosc/lib/text.lua
Normal file
@@ -0,0 +1,414 @@
|
||||
-- https://en.wikipedia.org/wiki/Unicode_block
|
||||
---@alias CodePointRange {[1]: integer; [2]: integer}
|
||||
|
||||
---@type CodePointRange[]
|
||||
local zero_width_blocks = {
|
||||
{0x0000, 0x001F}, -- C0
|
||||
{0x007F, 0x009F}, -- Delete + C1
|
||||
{0x034F, 0x034F}, -- combining grapheme joiner
|
||||
{0x061C, 0x061C}, -- Arabic Letter Strong
|
||||
{0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
|
||||
{0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
|
||||
{0x2060, 0x2060}, -- word joiner
|
||||
{0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
|
||||
{0xFEFF, 0xFEFF}, -- zero-width non-breaking space
|
||||
-- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
|
||||
{0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
|
||||
{0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
|
||||
{0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
|
||||
{0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
|
||||
{0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
|
||||
-- Egyptian Hieroglyph Format Controls and Shorthand format Controls
|
||||
{0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
|
||||
{0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
|
||||
-- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
|
||||
{0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
|
||||
}
|
||||
|
||||
-- All characters have the same width as the first one
|
||||
---@type CodePointRange[]
|
||||
local same_width_blocks = {
|
||||
{0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han
|
||||
{0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han
|
||||
{0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han
|
||||
{0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han
|
||||
{0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han
|
||||
{0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han
|
||||
{0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han
|
||||
{0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han
|
||||
{0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han
|
||||
{0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han
|
||||
}
|
||||
|
||||
---Get byte count of utf-8 character at index i in str
|
||||
---@param str string
|
||||
---@param i integer?
|
||||
---@return integer
|
||||
local function utf8_char_bytes(str, i)
|
||||
local char_byte = str:byte(i)
|
||||
if char_byte < 0xC0 then return 1
|
||||
elseif char_byte < 0xE0 then return 2
|
||||
elseif char_byte < 0xF0 then return 3
|
||||
elseif char_byte < 0xF8 then return 4
|
||||
else return 1 end
|
||||
end
|
||||
|
||||
---Creates an iterator for an utf-8 encoded string
|
||||
---Iterates over utf-8 characters instead of bytes
|
||||
---@param str string
|
||||
---@return fun(): string
|
||||
local function utf8_iter(str)
|
||||
local byte_start = 1
|
||||
return function()
|
||||
local start = byte_start
|
||||
if #str < start then return nil end
|
||||
local byte_count = utf8_char_bytes(str, start)
|
||||
byte_start = start + byte_count
|
||||
return start, str:sub(start, start + byte_count - 1)
|
||||
end
|
||||
end
|
||||
|
||||
---Extract Unicode code point from utf-8 character at index i in str
|
||||
---@param str string
|
||||
---@param i integer
|
||||
---@return integer
|
||||
local function utf8_to_unicode(str, i)
|
||||
local byte_count = utf8_char_bytes(str, i)
|
||||
local char_byte = str:byte(i)
|
||||
local unicode = char_byte
|
||||
if byte_count ~= 1 then
|
||||
local shift = 2 ^ (8 - byte_count)
|
||||
char_byte = char_byte - math.floor(0xFF / shift) * shift
|
||||
unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
|
||||
end
|
||||
for j = 2, byte_count do
|
||||
char_byte = str:byte(i + j - 1) - 0x80
|
||||
unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
|
||||
end
|
||||
return round(unicode)
|
||||
end
|
||||
|
||||
---Convert Unicode code point to utf-8 string
|
||||
---@param unicode integer
|
||||
---@return string?
|
||||
local function unicode_to_utf8(unicode)
|
||||
if unicode < 0x80 then return string.char(unicode)
|
||||
else
|
||||
local byte_count
|
||||
if unicode < 0x800 then byte_count = 2
|
||||
elseif unicode < 0x10000 then byte_count = 3
|
||||
elseif unicode < 0x110000 then byte_count = 4
|
||||
else return end -- too big
|
||||
|
||||
local res = {}
|
||||
local shift = 2 ^ 6
|
||||
local after_shift = unicode
|
||||
for _ = byte_count, 2, -1 do
|
||||
local before_shift = after_shift
|
||||
after_shift = math.floor(before_shift / shift)
|
||||
table.insert(res, 1, before_shift - after_shift * shift + 0x80)
|
||||
end
|
||||
shift = 2 ^ (8 - byte_count)
|
||||
table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift)
|
||||
---@diagnostic disable-next-line: deprecated
|
||||
return string.char(unpack(res))
|
||||
end
|
||||
end
|
||||
|
||||
local text_osd = mp.create_osd_overlay("ass-events")
|
||||
text_osd.compute_bounds, text_osd.hidden = true, true
|
||||
---@type integer, integer
|
||||
local osd_width, osd_height = 100, 100
|
||||
mp.observe_property('osd-dimensions', 'native', function (_, dim)
|
||||
if dim then osd_width, osd_height = dim.w, dim.h end
|
||||
end)
|
||||
|
||||
---@param ass_text string
|
||||
---@return integer, integer, integer, integer
|
||||
local function measure_bounds(ass_text)
|
||||
osd_width, osd_height = mp.get_osd_size()
|
||||
text_osd.res_x, text_osd.res_y = osd_width, osd_height
|
||||
text_osd.data = ass_text
|
||||
local res = text_osd:update()
|
||||
return res.x0, res.y0, res.x1, res.y1
|
||||
end
|
||||
|
||||
---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number}
|
||||
local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0}
|
||||
|
||||
---Measure text width and normalize to a font size of 1
|
||||
---text has to be ass safe
|
||||
---@param text string
|
||||
---@param size number
|
||||
---@param bold boolean
|
||||
---@param italic boolean
|
||||
---@param horizontal boolean
|
||||
---@return number, integer
|
||||
local function normalized_text_width(text, size, bold, italic, horizontal)
|
||||
bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90
|
||||
local x1, y1 = nil, nil
|
||||
size = size / 0.8
|
||||
-- prevent endless loop
|
||||
local repetitions_left = 5
|
||||
repeat
|
||||
size = size * 0.8
|
||||
bounds_opts.size = size
|
||||
local ass = assdraw.ass_new()
|
||||
ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts)
|
||||
_, _, x1, y1 = measure_bounds(ass.text)
|
||||
repetitions_left = repetitions_left - 1
|
||||
-- make sure nothing got clipped
|
||||
until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0
|
||||
local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1)
|
||||
return width / size, horizontal and osd_width or osd_height
|
||||
end
|
||||
|
||||
---Estimates character length based on utf8 byte count
|
||||
---1 character length is roughly the size of a latin character
|
||||
---@param char string
|
||||
---@return number
|
||||
local function char_length(char)
|
||||
return #char > 2 and 2 or 1
|
||||
end
|
||||
|
||||
---Estimates string length based on utf8 byte count
|
||||
---Note: Making a string in the iterator with the character is a waste here,
|
||||
---but as this function is only used when measuring whole string widths it's fine
|
||||
---@param text string
|
||||
---@return number
|
||||
local function text_length(text)
|
||||
if not text or text == '' then return 0 end
|
||||
local text_length = 0
|
||||
for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end
|
||||
return text_length
|
||||
end
|
||||
|
||||
local width_length_ratio = 0.5
|
||||
---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
|
||||
local char_width_cache = {}
|
||||
|
||||
---Finds the best orientation of text on screen and returns the estimated max size
|
||||
---and if the text should be drawn horizontally
|
||||
---@param text string
|
||||
---@return number, boolean
|
||||
local function fit_on_screen(text)
|
||||
local estimated_width = text_length(text) * width_length_ratio
|
||||
if osd_width >= osd_height then
|
||||
-- Fill the screen as much as we can, bigger is more accurate.
|
||||
return math.min(osd_width / estimated_width, osd_height), true
|
||||
else
|
||||
return math.min(osd_height / estimated_width, osd_width), false
|
||||
end
|
||||
end
|
||||
|
||||
---Gets next stage from cache
|
||||
---@param cache {[any]: table}
|
||||
---@param value any
|
||||
local function get_cache_stage(cache, value)
|
||||
local stage = cache[value]
|
||||
if not stage then
|
||||
stage = {}
|
||||
cache[value] = stage
|
||||
end
|
||||
return stage
|
||||
end
|
||||
|
||||
---Is measured resolution sufficient
|
||||
---@param px integer
|
||||
---@return boolean
|
||||
local function no_remeasure_required(px)
|
||||
return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height)
|
||||
end
|
||||
|
||||
---Get measured width of character
|
||||
---@param char string
|
||||
---@param bold boolean
|
||||
---@return number, integer
|
||||
local function character_width(char, bold)
|
||||
---@type {[string]: {[1]: number, [2]: integer}}
|
||||
local char_widths = get_cache_stage(char_width_cache, bold)
|
||||
local width_px = char_widths[char]
|
||||
if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end
|
||||
|
||||
local unicode = utf8_to_unicode(char, 1)
|
||||
for _, block in ipairs(zero_width_blocks) do
|
||||
if unicode >= block[1] and unicode <= block[2] then
|
||||
char_widths[char] = {0, infinity}
|
||||
return 0, infinity
|
||||
end
|
||||
end
|
||||
|
||||
local measured_char = nil
|
||||
for _, block in ipairs(same_width_blocks) do
|
||||
if unicode >= block[1] and unicode <= block[2] then
|
||||
measured_char = unicode_to_utf8(block[1])
|
||||
width_px = char_widths[measured_char]
|
||||
if width_px and no_remeasure_required(width_px[2]) then
|
||||
char_widths[char] = width_px
|
||||
return width_px[1], width_px[2]
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not measured_char then measured_char = char end
|
||||
-- half as many repetitions for wide characters
|
||||
local char_count = 10 / char_length(char)
|
||||
local max_size, horizontal = fit_on_screen(measured_char:rep(char_count))
|
||||
local size = math.min(max_size * 0.9, 50)
|
||||
char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100)
|
||||
local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count
|
||||
if measured_char == enclosing_char then enclosing_char = ''
|
||||
else enclosing_width = 2 * character_width(enclosing_char, bold) end
|
||||
local width_ratio, width, px = nil, nil, nil
|
||||
repeat
|
||||
char_count = next_char_count
|
||||
local str = enclosing_char .. measured_char:rep(char_count) .. enclosing_char
|
||||
width, px = normalized_text_width(str, size, bold, false, horizontal)
|
||||
width = width - enclosing_width
|
||||
width_ratio = width * size / (horizontal and osd_width or osd_height)
|
||||
next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100)
|
||||
until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count
|
||||
width = width / char_count
|
||||
|
||||
width_px = {width, px}
|
||||
if char ~= measured_char then char_widths[measured_char] = width_px end
|
||||
char_widths[char] = width_px
|
||||
return width, px
|
||||
end
|
||||
|
||||
---Calculate text width from individual measured characters
|
||||
---@param text string|number
|
||||
---@param bold boolean
|
||||
---@return number, integer
|
||||
local function character_based_width(text, bold)
|
||||
local max_width = 0
|
||||
local min_px = infinity
|
||||
for line in tostring(text):gmatch("([^\n]*)\n?") do
|
||||
local total_width = 0
|
||||
for _, char in utf8_iter(line) do
|
||||
local width, px = character_width(char, bold)
|
||||
total_width = total_width + width
|
||||
if px < min_px then min_px = px end
|
||||
end
|
||||
if total_width > max_width then max_width = total_width end
|
||||
end
|
||||
return max_width, min_px
|
||||
end
|
||||
|
||||
---Measure width of whole text
|
||||
---@param text string|number
|
||||
---@param bold boolean
|
||||
---@param italic boolean
|
||||
---@return number, integer
|
||||
local function whole_text_width(text, bold, italic)
|
||||
text = tostring(text)
|
||||
local size, horizontal = fit_on_screen(text)
|
||||
return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal)
|
||||
end
|
||||
|
||||
---Get scale factor calculated from font size, bold and italic
|
||||
---@param opts {size: number; bold?: boolean; italic?: boolean}
|
||||
local function opts_scale_factor(opts)
|
||||
return (opts.italic and 1.01 or 1) * opts.size
|
||||
end
|
||||
|
||||
---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}
|
||||
local width_cache = {}
|
||||
|
||||
---Calculate width of text with the given opts
|
||||
---@param text string|number
|
||||
---@return number
|
||||
---@param opts {size: number; bold?: boolean; italic?: boolean}
|
||||
function text_width(text, opts)
|
||||
if not text or text == '' then return 0 end
|
||||
|
||||
---@type boolean, boolean
|
||||
local bold, italic = opts.bold or false, opts.italic or false
|
||||
|
||||
if options.text_width_estimation then
|
||||
---@type {[string|number]: {[1]: number, [2]: integer}}
|
||||
local text_width = get_cache_stage(width_cache, bold)
|
||||
local width_px = text_width[text]
|
||||
if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts_scale_factor(opts) end
|
||||
|
||||
local width, px = character_based_width(text, bold)
|
||||
width_cache[bold][text] = {width, px}
|
||||
return width * opts_scale_factor(opts)
|
||||
else
|
||||
---@type {[string|number]: {[1]: number, [2]: integer}}
|
||||
local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic)
|
||||
local width_px = text_width[text]
|
||||
if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end
|
||||
|
||||
local width, px = whole_text_width(text, bold, italic)
|
||||
width_cache[bold][italic][text] = {width, px}
|
||||
return width * opts.size
|
||||
end
|
||||
end
|
||||
|
||||
---Wrap the text at the closest opportunity to target_line_length
|
||||
---@param text string
|
||||
---@param opts {size: number; bold?: boolean; italic?: boolean}
|
||||
---@param target_line_length number
|
||||
---@return string
|
||||
function wrap_text(text, opts, target_line_length)
|
||||
local target_line_width = target_line_length * width_length_ratio * opts.size
|
||||
local bold, scale_factor = opts.bold or false, opts_scale_factor(opts)
|
||||
local wrap_at_chars = {' ', ' ', '-', '–'}
|
||||
local remove_when_wrap = {' ', ' '}
|
||||
local lines = {}
|
||||
for text_line in text:gmatch("([^\n]*)\n?") do
|
||||
local line_width = 0
|
||||
local line_start = 1
|
||||
local before_end = nil
|
||||
local before_width = 0
|
||||
local before_line_start = 0
|
||||
local before_removed_width = 0
|
||||
for char_start, char in utf8_iter(text_line) do
|
||||
local char_end = char_start + #char - 1
|
||||
local can_wrap = false
|
||||
for _, c in ipairs(wrap_at_chars) do
|
||||
if char == c then
|
||||
can_wrap = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local char_width = character_width(char, bold) * scale_factor
|
||||
line_width = line_width + char_width
|
||||
if can_wrap or (char_end == #text_line) then
|
||||
local remove = false
|
||||
for _, c in ipairs(remove_when_wrap) do
|
||||
if char == c then
|
||||
remove = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local line_width_after_remove = line_width - (remove and char_width or 0)
|
||||
if line_width_after_remove < target_line_width then
|
||||
before_end = remove and char_start - 1 or char_end
|
||||
before_width = line_width_after_remove
|
||||
before_line_start = char_end + 1
|
||||
before_removed_width = remove and char_width or 0
|
||||
else
|
||||
if (target_line_width - before_width) <
|
||||
(line_width_after_remove - target_line_width) then
|
||||
lines[#lines + 1] = text_line:sub(line_start, before_end)
|
||||
line_start = before_line_start
|
||||
line_width = line_width - before_width - before_removed_width
|
||||
else
|
||||
lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
|
||||
line_start = char_end + 1
|
||||
line_width = remove and line_width - char_width or line_width
|
||||
line_width = 0
|
||||
end
|
||||
before_end = line_start
|
||||
before_width = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
if #text_line >= line_start then lines[#lines + 1] = text_line:sub(line_start)
|
||||
elseif text_line == '' then lines[#lines + 1] = '' end
|
||||
end
|
||||
return table.concat(lines, '\n')
|
||||
end
|
481
scripts/uosc/lib/utils.lua
Normal file
481
scripts/uosc/lib/utils.lua
Normal file
@@ -0,0 +1,481 @@
|
||||
--[[ UI specific utilities that might or might not depend on its state or options ]]
|
||||
|
||||
-- Sorting comparator close to (but not exactly) how file explorers sort files
|
||||
file_order_comparator = (function()
|
||||
local symbol_order
|
||||
local default_order
|
||||
|
||||
if state.os == 'win' then
|
||||
symbol_order = {
|
||||
['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7,
|
||||
['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14,
|
||||
['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20,
|
||||
}
|
||||
default_order = 21
|
||||
else
|
||||
symbol_order = {
|
||||
['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8,
|
||||
['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14,
|
||||
['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23,
|
||||
}
|
||||
default_order = 21
|
||||
end
|
||||
|
||||
-- Alphanumeric sorting for humans in Lua
|
||||
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
|
||||
local function pad_number(d)
|
||||
local dec, n = d:match("(%.?)0*(.+)")
|
||||
return #dec > 0 and ("%.12f"):format(d) or ("%03d%s"):format(#n, n)
|
||||
end
|
||||
|
||||
---@param a string|number
|
||||
---@param b string|number
|
||||
---@return boolean
|
||||
return function(a, b)
|
||||
a, b = tostring(a), tostring(b)
|
||||
local ai = a:sub(1, 1)
|
||||
local bi = b:sub(1, 1)
|
||||
if ai == nil and bi then return true end
|
||||
if bi == nil and ai then return false end
|
||||
local a_order = symbol_order[ai] or default_order
|
||||
local b_order = symbol_order[bi] or default_order
|
||||
if a_order ~= b_order then return a_order < b_order end
|
||||
return a:lower():gsub("%.?%d+", pad_number)..("%3d"):format(#b)
|
||||
< b:lower():gsub("%.?%d+", pad_number)..("%3d"):format(#a)
|
||||
end
|
||||
end)()
|
||||
|
||||
-- Creates in-between frames to animate value from `from` to `to` numbers.
|
||||
---@param from number
|
||||
---@param to number|fun():number
|
||||
---@param setter fun(value: number)
|
||||
---@param factor_or_callback? number|fun()
|
||||
---@param callback? fun() Called either on animation end, or when animation is killed.
|
||||
function tween(from, to, setter, factor_or_callback, callback)
|
||||
local factor = factor_or_callback
|
||||
if type(factor_or_callback) == 'function' then callback = factor_or_callback end
|
||||
if type(factor) ~= 'number' then factor = 0.3 end
|
||||
|
||||
local current, done, timeout = from, false, nil
|
||||
local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
|
||||
local cutoff = math.abs(get_to() - from) * 0.01
|
||||
|
||||
local function finish()
|
||||
if not done then
|
||||
done = true
|
||||
timeout:kill()
|
||||
if callback then callback() end
|
||||
end
|
||||
end
|
||||
|
||||
local function tick()
|
||||
local to = get_to()
|
||||
current = current + ((to - current) * factor)
|
||||
local is_end = math.abs(to - current) <= cutoff
|
||||
setter(is_end and to or current)
|
||||
request_render()
|
||||
if is_end then finish()
|
||||
else timeout:resume() end
|
||||
end
|
||||
|
||||
timeout = mp.add_timeout(state.render_delay, tick)
|
||||
tick()
|
||||
|
||||
return finish
|
||||
end
|
||||
|
||||
---@param point {x: number; y: number}
|
||||
---@param rect {ax: number; ay: number; bx: number; by: number}
|
||||
function get_point_to_rectangle_proximity(point, rect)
|
||||
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
|
||||
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
end
|
||||
|
||||
---Extracts the properties used by property expansion of that string.
|
||||
---@param str string
|
||||
---@param res { [string] : boolean } | nil
|
||||
---@return { [string] : boolean }
|
||||
function get_expansion_props(str, res)
|
||||
res = res or {}
|
||||
for str in str:gmatch('%$(%b{})') do
|
||||
local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$')
|
||||
if name then
|
||||
local s = name:find('==') or nil
|
||||
if s then name = name:sub(0, s - 1) end
|
||||
res[name] = true
|
||||
if str and str ~= '' then get_expansion_props(str, res) end
|
||||
end
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
-- Escape a string for verbatim display on the OSD
|
||||
---@param str string
|
||||
function ass_escape(str)
|
||||
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
||||
-- it isn't followed by a recognized character, so add a zero-width
|
||||
-- non-breaking space
|
||||
str = str:gsub('\\', '\\\239\187\191')
|
||||
str = str:gsub('{', '\\{')
|
||||
str = str:gsub('}', '\\}')
|
||||
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
||||
-- consecutive newlines
|
||||
str = str:gsub('\n', '\239\187\191\\N')
|
||||
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
|
||||
str = str:gsub('\\N ', '\\N\\h')
|
||||
str = str:gsub('^ ', '\\h')
|
||||
return str
|
||||
end
|
||||
|
||||
---@param seconds number
|
||||
---@return string
|
||||
function format_time(seconds)
|
||||
local human = mp.format_time(seconds)
|
||||
if options.time_precision > 0 then
|
||||
local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
|
||||
human = human .. '.' .. string.sub(formatted, 3)
|
||||
end
|
||||
return human
|
||||
end
|
||||
|
||||
---@param opacity number 0-1
|
||||
function opacity_to_alpha(opacity)
|
||||
return 255 - math.ceil(255 * opacity)
|
||||
end
|
||||
|
||||
-- Ensures path is absolute and normalizes slashes to the current platform
|
||||
---@param path string
|
||||
function normalize_path(path)
|
||||
if not path or is_protocol(path) then return path end
|
||||
|
||||
-- Ensure path is absolute
|
||||
if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then
|
||||
path = utils.join_path(state.cwd, path)
|
||||
end
|
||||
|
||||
-- Remove trailing slashes
|
||||
if #path > 1 then
|
||||
path = path:gsub('[\\/]+$', '')
|
||||
path = #path == 0 and '/' or path
|
||||
end
|
||||
|
||||
-- Use proper slashes
|
||||
if state.os == 'windows' then
|
||||
-- Drive letters on windows need trailing backslash
|
||||
if path:sub(#path) == ':' then
|
||||
path = path .. '\\'
|
||||
end
|
||||
|
||||
return path:gsub('/', '\\')
|
||||
else
|
||||
return path:gsub('\\', '/')
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if path is a protocol, such as `http://...`
|
||||
---@param path string
|
||||
function is_protocol(path)
|
||||
return type(path) == 'string' and (path:match('^%a[%a%d-_]+://') ~= nil or path:match('^%a[%a%d-_]+:\\?') ~= nil)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
function get_extension(path)
|
||||
local parts = split(path, '%.')
|
||||
return parts and #parts > 1 and parts[#parts] or nil
|
||||
end
|
||||
|
||||
---@return string
|
||||
function get_default_directory()
|
||||
return mp.command_native({'expand-path', options.default_directory})
|
||||
end
|
||||
|
||||
-- Serializes path into its semantic parts
|
||||
---@param path string
|
||||
---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
|
||||
function serialize_path(path)
|
||||
if not path or is_protocol(path) then return end
|
||||
|
||||
local normal_path = normalize_path(path)
|
||||
-- normalize_path() already strips slashes, but leaves trailing backslash
|
||||
-- for windows drive letters, but we don't need it here.
|
||||
local working_path = normal_path:sub(#normal_path) == '\\' and normal_path:sub(1, #normal_path - 1) or normal_path
|
||||
local parts = split(working_path, '[\\/]+')
|
||||
local basename = parts and parts[#parts] or working_path
|
||||
local dirname = #parts > 1
|
||||
and table.concat(itable_slice(parts, 1, #parts - 1), state.os == 'windows' and '\\' or '/')
|
||||
or nil
|
||||
local dot_split = split(basename, '%.')
|
||||
|
||||
return {
|
||||
path = normal_path,
|
||||
is_root = dirname == nil,
|
||||
dirname = dirname,
|
||||
basename = basename,
|
||||
filename = #dot_split > 1 and table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or basename,
|
||||
extension = #dot_split > 1 and dot_split[#dot_split] or nil,
|
||||
}
|
||||
end
|
||||
|
||||
---@param directory string
|
||||
---@param allowed_types? string[]
|
||||
---@return nil|string[]
|
||||
function get_files_in_directory(directory, allowed_types)
|
||||
local files, error = utils.readdir(directory, 'files')
|
||||
|
||||
if not files then
|
||||
msg.error('Retrieving files failed: ' .. (error or ''))
|
||||
return
|
||||
end
|
||||
|
||||
-- Filter only requested file types
|
||||
if allowed_types then
|
||||
files = itable_filter(files, function(file)
|
||||
local extension = get_extension(file)
|
||||
return extension and itable_index_of(allowed_types, extension:lower())
|
||||
end)
|
||||
end
|
||||
|
||||
table.sort(files, file_order_comparator)
|
||||
|
||||
return files
|
||||
end
|
||||
|
||||
-- Returns full absolute paths of files in the same directory as file_path,
|
||||
-- and index of the current file in the table.
|
||||
---@param file_path string
|
||||
---@param allowed_types? string[]
|
||||
function get_adjacent_paths(file_path, allowed_types)
|
||||
local current_file = serialize_path(file_path)
|
||||
if not current_file then return end
|
||||
local files = get_files_in_directory(current_file.dirname, allowed_types)
|
||||
if not files then return end
|
||||
local current_file_index
|
||||
local paths = {}
|
||||
for index, file in ipairs(files) do
|
||||
paths[#paths + 1] = utils.join_path(current_file.dirname, file)
|
||||
if current_file.basename == file then current_file_index = index end
|
||||
end
|
||||
if not current_file_index then return end
|
||||
return paths, current_file_index
|
||||
end
|
||||
|
||||
-- Navigates in a list, using delta or, when `state.shuffle` is enabled,
|
||||
-- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
|
||||
---@param list table
|
||||
---@param current_index number
|
||||
---@param delta number
|
||||
function decide_navigation_in_list(list, current_index, delta)
|
||||
if #list < 2 then return #list, list[#list] end
|
||||
|
||||
if state.shuffle then
|
||||
local new_index = current_index
|
||||
math.randomseed(os.time())
|
||||
while current_index == new_index do new_index = math.random(#list) end
|
||||
return new_index, list[new_index]
|
||||
end
|
||||
|
||||
local new_index = current_index + delta
|
||||
if mp.get_property_native('loop-playlist') then
|
||||
if new_index > #list then new_index = new_index % #list
|
||||
elseif new_index < 1 then new_index = #list - new_index end
|
||||
elseif new_index < 1 or new_index > #list then
|
||||
return
|
||||
end
|
||||
|
||||
return new_index, list[new_index]
|
||||
end
|
||||
|
||||
---@param delta number
|
||||
function navigate_directory(delta)
|
||||
if not state.path or is_protocol(state.path) then return false end
|
||||
local paths, current_index = get_adjacent_paths(state.path, config.media_types)
|
||||
if paths and current_index then
|
||||
local _, path = decide_navigation_in_list(paths, current_index, delta)
|
||||
if path then mp.commandv('loadfile', path) return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@param delta number
|
||||
function navigate_playlist(delta)
|
||||
local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
|
||||
if playlist and #playlist > 1 and pos then
|
||||
local index = decide_navigation_in_list(playlist, pos, delta)
|
||||
if index then mp.commandv('playlist-play-index', index - 1) return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@param delta number
|
||||
function navigate_item(delta)
|
||||
if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
|
||||
end
|
||||
|
||||
-- Can't use `os.remove()` as it fails on paths with unicode characters.
|
||||
-- Returns `result, error`, result is table of:
|
||||
-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
|
||||
---@param path string
|
||||
function delete_file(path)
|
||||
local args = state.os == 'windows' and {'cmd', '/C', 'del', path} or {'rm', path}
|
||||
return mp.command_native({
|
||||
name = 'subprocess',
|
||||
args = args,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
})
|
||||
end
|
||||
|
||||
function serialize_chapter_ranges(normalized_chapters)
|
||||
local ranges = {}
|
||||
local simple_ranges = {
|
||||
{name = 'openings', patterns = {'^op ', '^op$', ' op$', 'opening$'}, requires_next_chapter = true},
|
||||
{name = 'intros', patterns = {'^intro$'}, requires_next_chapter = true},
|
||||
{name = 'endings', patterns = {'^ed ', '^ed$', ' ed$', 'ending$', 'closing$'}},
|
||||
{name = 'outros', patterns = {'^outro$'}},
|
||||
}
|
||||
local sponsor_ranges = {}
|
||||
|
||||
-- Extend with alt patterns
|
||||
for _, meta in ipairs(simple_ranges) do
|
||||
local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
|
||||
if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
|
||||
end
|
||||
|
||||
-- Clone chapters
|
||||
local chapters = {}
|
||||
for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end
|
||||
|
||||
for i, chapter in ipairs(chapters) do
|
||||
-- Simple ranges
|
||||
for _, meta in ipairs(simple_ranges) do
|
||||
if config.chapter_ranges[meta.name] then
|
||||
local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
|
||||
if match then
|
||||
local next_chapter = chapters[i + 1]
|
||||
if next_chapter or not meta.requires_next_chapter then
|
||||
ranges[#ranges + 1] = table_assign({
|
||||
start = chapter.time,
|
||||
['end'] = next_chapter and next_chapter.time or infinity,
|
||||
}, config.chapter_ranges[meta.name])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Sponsor blocks
|
||||
if config.chapter_ranges.ads then
|
||||
local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
|
||||
if id then -- ad range from sponsorblock
|
||||
for j = i + 1, #chapters, 1 do
|
||||
local end_chapter = chapters[j]
|
||||
local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
|
||||
if end_match then
|
||||
local range = table_assign({
|
||||
start_chapter = chapter, end_chapter = end_chapter,
|
||||
start = chapter.time, ['end'] = end_chapter.time,
|
||||
}, config.chapter_ranges.ads)
|
||||
ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
|
||||
end_chapter.is_end_only = true
|
||||
break
|
||||
end
|
||||
end -- single chapter for ad
|
||||
elseif not chapter.is_end_only and
|
||||
(chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then
|
||||
local next_chapter = chapters[i + 1]
|
||||
ranges[#ranges + 1] = table_assign({
|
||||
start = chapter.time,
|
||||
['end'] = next_chapter and next_chapter.time or infinity,
|
||||
}, config.chapter_ranges.ads)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fix overlapping sponsor block segments
|
||||
for index, range in ipairs(sponsor_ranges) do
|
||||
local next_range = sponsor_ranges[index + 1]
|
||||
if next_range then
|
||||
local delta = next_range.start - range['end']
|
||||
if delta < 0 then
|
||||
local mid_point = range['end'] + delta / 2
|
||||
range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
|
||||
next_range.start, next_range.start_chapter.time = mid_point, mid_point
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(chapters, function(a, b) return a.time < b.time end)
|
||||
|
||||
return chapters, ranges
|
||||
end
|
||||
|
||||
-- Ensures chapters are in chronological order
|
||||
function normalize_chapters(chapters)
|
||||
if not chapters then return {} end
|
||||
-- Ensure chronological order
|
||||
table.sort(chapters, function(a, b) return a.time < b.time end)
|
||||
-- Ensure titles
|
||||
for index, chapter in ipairs(chapters) do
|
||||
chapter.title = chapter.title or ('Chapter ' .. index)
|
||||
chapter.lowercase_title = chapter.title:lower()
|
||||
end
|
||||
return chapters
|
||||
end
|
||||
|
||||
function serialize_chapters(chapters)
|
||||
chapters = normalize_chapters(chapters)
|
||||
if not chapters then return end
|
||||
--- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
|
||||
local opts = {size = 1, bold = true}
|
||||
for index, chapter in ipairs(chapters) do
|
||||
chapter.index = index
|
||||
chapter.title_wrapped = wrap_text(chapter.title, opts, 25)
|
||||
chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
|
||||
chapter.title_wrapped = ass_escape(chapter.title_wrapped)
|
||||
end
|
||||
return chapters
|
||||
end
|
||||
|
||||
--[[ RENDERING ]]
|
||||
|
||||
function render()
|
||||
state.render_last_time = mp.get_time()
|
||||
|
||||
-- Actual rendering
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
for _, element in Elements:ipairs() do
|
||||
if element.enabled then
|
||||
local result = element:maybe('render')
|
||||
if result then
|
||||
ass:new_event()
|
||||
ass:merge(result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- submit
|
||||
if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
|
||||
return
|
||||
end
|
||||
|
||||
osd.res_x = display.width
|
||||
osd.res_y = display.height
|
||||
osd.data = ass.text
|
||||
osd.z = 2000
|
||||
osd:update()
|
||||
|
||||
update_margins()
|
||||
end
|
||||
|
||||
-- Request that render() is called.
|
||||
-- The render is then either executed immediately, or rate-limited if it was
|
||||
-- called a small time ago.
|
||||
state.render_timer = mp.add_timeout(0, render)
|
||||
state.render_timer:kill()
|
||||
function request_render()
|
||||
if state.render_timer:is_enabled() then return end
|
||||
local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
|
||||
state.render_timer.timeout = timeout
|
||||
state.render_timer:resume()
|
||||
end
|
1073
scripts/uosc/main.lua
Normal file
1073
scripts/uosc/main.lua
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user