mpv config added
This commit is contained in:
39
.config/mpv/scripts/uosc/elements/BufferingIndicator.lua
Normal file
39
.config/mpv/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, 'buffering_indicator', {ignores_curtain = true, render_order = 2})
|
||||
self.enabled = false
|
||||
self:decide_enabled()
|
||||
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 = config.opacity.buffering_indicator})
|
||||
local size = round(30 + math.min(display.width, display.height) / 10)
|
||||
local opacity = (Elements.menu and Elements.menu:is_alive()) and 0.3 or 0.8
|
||||
ass:spinner(display.width / 2, display.height / 2, size, {color = fg, opacity = opacity})
|
||||
return ass
|
||||
end
|
||||
|
||||
return BufferingIndicator
|
100
.config/mpv/scripts/uosc/elements/Button.lua
Normal file
100
.config/mpv/scripts/uosc/elements/Button.lua
Normal file
@@ -0,0 +1,100 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; 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
|
||||
self.is_clickable = true
|
||||
---@type fun()|nil
|
||||
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:handle_cursor_click()
|
||||
if not self.on_click or not self.is_clickable then return end
|
||||
-- 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 event 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
|
||||
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local is_clickable = self.is_clickable and self.on_click ~= nil
|
||||
local is_hover = self.proximity_raw == 0
|
||||
local foreground = self.active and self.background or self.foreground
|
||||
local background = self.active and self.foreground or self.background
|
||||
local background_opacity = self.active and 1 or config.opacity.controls
|
||||
|
||||
if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
|
||||
|
||||
-- Background
|
||||
if background_opacity > 0 then
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
||||
color = (self.active or not is_hover) and background or foreground,
|
||||
radius = state.radius,
|
||||
opacity = visibility * background_opacity,
|
||||
})
|
||||
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 = state.radius,
|
||||
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 * state.scale,
|
||||
border_color = background,
|
||||
opacity = visibility,
|
||||
clip = icon_clip,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Button
|
389
.config/mpv/scripts/uosc/elements/Controls.lua
Normal file
389
.config/mpv/scripts/uosc/elements/Controls.lua
Normal file
@@ -0,0 +1,389 @@
|
||||
local Element = require('elements/Element')
|
||||
local Button = require('elements/Button')
|
||||
local CycleButton = require('elements/CycleButton')
|
||||
local ManagedButton = require('elements/ManagedButton')
|
||||
local Speed = require('elements/Speed')
|
||||
|
||||
-- sizing:
|
||||
-- static - shrink, have highest claim on available space, disappear when there's not enough of it
|
||||
-- dynamic - shrink to make room for static elements until they reach their ratio_min, then disappear
|
||||
-- gap - shrink if there's no space left
|
||||
-- space - expands to fill available space, shrinks as needed
|
||||
-- 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' | 'gap'; 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', {render_order = 6})
|
||||
---@type ControlItem[] All control elements serialized from `options.controls`.
|
||||
self.controls = {}
|
||||
---@type ControlItem[] Only controls that match current dispositions.
|
||||
self.layout = {}
|
||||
|
||||
self:init_options()
|
||||
end
|
||||
|
||||
function Controls:destroy()
|
||||
self:destroy_elements()
|
||||
Element.destroy(self)
|
||||
end
|
||||
|
||||
function Controls:init_options()
|
||||
-- Serialize control elements
|
||||
local shorthands = {
|
||||
['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
|
||||
menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
|
||||
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
|
||||
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
|
||||
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
|
||||
video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
|
||||
playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
|
||||
chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
|
||||
['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
|
||||
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
|
||||
['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
|
||||
['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
|
||||
prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
|
||||
next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
|
||||
first = 'command:first_page:script-binding uosc/first?' .. t('First'),
|
||||
last = 'command:last_page:script-binding uosc/last?' .. t('Last'),
|
||||
['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?' .. t('Loop playlist'),
|
||||
['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?' .. t('Loop file'),
|
||||
shuffle = 'toggle:shuffle:shuffle?' .. t('Shuffle'),
|
||||
autoload = 'toggle:hdr_auto:autoload@uosc?' .. t('Autoload'),
|
||||
fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?' .. t('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(comma_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 = 'gap', 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, {
|
||||
render_order = self.render_order,
|
||||
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, {
|
||||
render_order = self.render_order,
|
||||
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 == 'button' then
|
||||
if #params ~= 1 then
|
||||
mp.error(string.format(
|
||||
'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
|
||||
))
|
||||
else
|
||||
local element = ManagedButton:new('control_' .. i, {
|
||||
name = params[1],
|
||||
render_order = self.render_order,
|
||||
anchor_id = 'controls',
|
||||
})
|
||||
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||
end
|
||||
elseif kind == 'speed' then
|
||||
if not Elements.speed then
|
||||
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
|
||||
local scale = tonumber(params[1]) or 1.3
|
||||
table_assign(control, {
|
||||
element = element, sizing = 'dynamic', scale = scale, 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
|
||||
self:observe_mp_property(observable_name, handler)
|
||||
end
|
||||
end
|
||||
|
||||
function Controls:get_visibility()
|
||||
return Elements:v('speed', 'dragging') and 1 or Elements:maybe('timeline', 'get_is_hovered')
|
||||
and -1 or Element.get_visibility(self)
|
||||
end
|
||||
|
||||
function Controls:update_dimensions()
|
||||
local window_border = Elements:v('window_border', 'size', 0)
|
||||
local size = round(options.controls_size * state.scale)
|
||||
local spacing = round(options.controls_spacing * state.scale)
|
||||
local margin = round(options.controls_margin * state.scale)
|
||||
|
||||
-- Disable when not enough space
|
||||
local available_space = display.height - window_border * 2 - Elements:v('top_bar', 'size', 0)
|
||||
- Elements:v('timeline', 'size', 0)
|
||||
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:v('timeline', 'ay', display.height - window_border) - margin
|
||||
self.ax, self.ay = window_border + margin, self.by - size
|
||||
|
||||
-- Controls
|
||||
local available_width, statics_width = self.bx - self.ax, 0
|
||||
local min_content_width = statics_width
|
||||
local max_dynamics_width, dynamic_units, spaces, gaps = 0, 0, 0, 0
|
||||
|
||||
-- Calculate statics_width, min_content_width, and count spaces & gaps
|
||||
for c, control in ipairs(self.layout) do
|
||||
if control.sizing == 'space' then
|
||||
spaces = spaces + 1
|
||||
elseif control.sizing == 'gap' then
|
||||
gaps = gaps + control.scale * control.ratio
|
||||
elseif control.sizing == 'static' then
|
||||
local width = size * control.scale * control.ratio + (c ~= #self.layout and spacing or 0)
|
||||
statics_width = statics_width + width
|
||||
min_content_width = min_content_width + width
|
||||
elseif control.sizing == 'dynamic' then
|
||||
local spacing = (c ~= #self.layout and spacing or 0)
|
||||
statics_width = statics_width + spacing
|
||||
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
|
||||
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.sizing ~= 'gap' and control.sizing ~= '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
|
||||
statics_width = statics_width - spacing
|
||||
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 empty_space_width = width_for_dynamics - max_dynamics_width
|
||||
local width_for_gaps = math.min(empty_space_width, size * gaps)
|
||||
local individual_space_width = spaces > 0 and ((empty_space_width - width_for_gaps) / spaces) or 0
|
||||
|
||||
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 individual_space_width > 0 then width = individual_space_width end
|
||||
elseif sizing == 'gap' then
|
||||
if width_for_gaps > 0 then width = width_for_gaps * (ratio / gaps) 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 = element and bx + spacing or bx
|
||||
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_title_bar() self:update_dimensions() end
|
||||
function Controls:on_prop_fullormaxed() self:update_dimensions() end
|
||||
function Controls:on_timeline_enabled() self:update_dimensions() end
|
||||
|
||||
function Controls:destroy_elements()
|
||||
for _, control in ipairs(self.controls) do
|
||||
if control.element then control.element:destroy() end
|
||||
end
|
||||
end
|
||||
|
||||
function Controls:on_options()
|
||||
self:destroy_elements()
|
||||
self:init_options()
|
||||
end
|
||||
|
||||
return Controls
|
35
.config/mpv/scripts/uosc/elements/Curtain.lua
Normal file
35
.config/mpv/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', {render_order = 999})
|
||||
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 config.opacity.curtain == 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
ass:rect(0, 0, display.width, display.height, {
|
||||
color = config.color.curtain, opacity = config.opacity.curtain * self.opacity,
|
||||
})
|
||||
return ass
|
||||
end
|
||||
|
||||
return Curtain
|
86
.config/mpv/scripts/uosc/elements/CycleButton.lua
Normal file
86
.config/mpv/scripts/uosc/elements/CycleButton.lua
Normal file
@@ -0,0 +1,86 @@
|
||||
local Button = require('elements/Button')
|
||||
|
||||
---@alias CycleState {value: any; icon: string; active?: boolean}
|
||||
---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
|
||||
|
||||
local function yes_no_to_boolean(value)
|
||||
if type(value) ~= 'string' then return value end
|
||||
local lowercase = trim(value):lower()
|
||||
if lowercase == 'yes' or lowercase == 'no' then
|
||||
return lowercase == 'yes'
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
|
||||
---@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 == 'uosc' then
|
||||
if type(options[self.prop]) == 'number' then
|
||||
options[self.prop] = tonumber(new_value) or 0
|
||||
else
|
||||
options[self.prop] = yes_no_to_boolean(new_value)
|
||||
end
|
||||
handle_options({[self.prop] = options[self.prop]})
|
||||
elseif self.owner then
|
||||
mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
|
||||
elseif is_state_prop then
|
||||
set_state(self.prop, yes_no_to_boolean(new_value))
|
||||
else
|
||||
mp.set_property(self.prop, new_value)
|
||||
end
|
||||
end
|
||||
|
||||
local function handle_change(name, value)
|
||||
-- Removes unnecessary floating point digits from values like `2.00000`.
|
||||
-- This happens when observing properties like `speed`.
|
||||
if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
|
||||
value = tonumber(value)
|
||||
end
|
||||
|
||||
value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
|
||||
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]
|
||||
if self.owner == 'uosc' then
|
||||
self['on_options'] = function() handle_change(self.prop, options[self.prop]) end
|
||||
handle_change(self.prop, options[self.prop])
|
||||
else
|
||||
self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
|
||||
handle_change(self.prop, external[self.prop])
|
||||
end
|
||||
elseif is_state_prop then -- uosc's state props
|
||||
self['on_prop_' .. self.prop] = function(self, value) handle_change(self.prop, value) end
|
||||
handle_change(self.prop, state[self.prop])
|
||||
else
|
||||
self:observe_mp_property(self.prop, 'string', handle_change)
|
||||
end
|
||||
end
|
||||
|
||||
return CycleButton
|
260
.config/mpv/scripts/uosc/elements/Element.lua
Normal file
260
.config/mpv/scripts/uosc/elements/Element.lua
Normal file
@@ -0,0 +1,260 @@
|
||||
---@alias ElementProps {enabled?: boolean; render_order?: number; ax?: number; ay?: number; bx?: number; by?: number; ignores_curtain?: 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
|
||||
self.render_order = 1
|
||||
-- `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 = math.huge
|
||||
---@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 Show this element even when curtain is visible.
|
||||
self.ignores_curtain = false
|
||||
---@type nil|string ID of an element from which this one should inherit visibility.
|
||||
self.anchor_id = nil
|
||||
---@type fun()[] Disposer functions called when element is destroyed.
|
||||
self._disposers = {}
|
||||
---@type table<string,table<string, boolean>> Namespaced active key bindings. Default namespace is `_`.
|
||||
self._key_bindings = {}
|
||||
|
||||
if props then table_assign(self, props) end
|
||||
|
||||
-- Flash timer
|
||||
self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
|
||||
local function getTo() return self.proximity end
|
||||
local function onTweenEnd() self.forced_visibility = nil end
|
||||
if self.enabled then
|
||||
self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
|
||||
else
|
||||
onTweenEnd()
|
||||
end
|
||||
end)
|
||||
self._flash_out_timer:kill()
|
||||
|
||||
Elements:add(self)
|
||||
end
|
||||
|
||||
function Element:destroy()
|
||||
for _, disposer in ipairs(self._disposers) do disposer() end
|
||||
self.destroyed = true
|
||||
self:remove_key_bindings()
|
||||
Elements:remove(self)
|
||||
end
|
||||
|
||||
function Element:reset_proximity() self.proximity, self.proximity_raw = 0, math.huge 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:reset_proximity()
|
||||
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
|
||||
|
||||
function Element:is_persistent()
|
||||
local persist = config[self.id .. '_persistency']
|
||||
return persist and (
|
||||
(persist.audio and state.is_audio)
|
||||
or (
|
||||
persist.paused and state.pause
|
||||
and (not Elements.timeline or not Elements.timeline.pressed or Elements.timeline.pressed.pause)
|
||||
)
|
||||
or (persist.video and state.is_video)
|
||||
or (persist.image and state.is_image)
|
||||
or (persist.idle and state.is_idle)
|
||||
or (persist.windowed and not state.fullormaxed)
|
||||
or (persist.fullscreen and state.fullormaxed)
|
||||
)
|
||||
end
|
||||
|
||||
-- Decide elements visibility based on proximity and various other factors
|
||||
function Element:get_visibility()
|
||||
-- Hide when curtain is visible, unless this elements ignores it
|
||||
local min_order = (Elements.curtain.opacity > 0 and not self.ignores_curtain) and Elements.curtain.render_order or 0
|
||||
if self.render_order < min_order then return 0 end
|
||||
|
||||
-- Persistency
|
||||
if self:is_persistent() 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 duration_or_callback? number|fun() Duration in milliseconds or a callback function.
|
||||
---@param callback? fun() Called either on animation end, or when animation is killed.
|
||||
function Element:tween(from, to, setter, duration_or_callback, callback)
|
||||
self:tween_stop()
|
||||
self._kill_tween = self.enabled and tween(
|
||||
from, to, setter, duration_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 duration_or_callback? number|fun() Duration in milliseconds or a callback function.
|
||||
---@param callback? fun() Called either on animation end, or when animation is killed.
|
||||
function Element:tween_property(prop, from, to, duration_or_callback, callback)
|
||||
self:tween(from, to, function(value) self[prop] = value end, duration_or_callback, callback)
|
||||
end
|
||||
|
||||
---@param name string
|
||||
function Element:trigger(name, ...)
|
||||
local result = self:maybe('on_' .. name, ...)
|
||||
request_render()
|
||||
return result
|
||||
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 self.enabled and 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.timeout = options.flash_duration / 1000
|
||||
self._flash_out_timer:kill()
|
||||
self._flash_out_timer:resume()
|
||||
end
|
||||
end
|
||||
|
||||
-- Register disposer to be called when element is destroyed.
|
||||
---@param disposer fun()
|
||||
function Element:register_disposer(disposer)
|
||||
if not itable_index_of(self._disposers, disposer) then
|
||||
self._disposers[#self._disposers + 1] = disposer
|
||||
end
|
||||
end
|
||||
|
||||
-- Automatically registers disposer for the passed callback.
|
||||
---@param event string
|
||||
---@param callback fun()
|
||||
function Element:register_mp_event(event, callback)
|
||||
mp.register_event(event, callback)
|
||||
self:register_disposer(function() mp.unregister_event(callback) end)
|
||||
end
|
||||
|
||||
-- Automatically registers disposer for the observer.
|
||||
---@param name string
|
||||
---@param type_or_callback string|fun(name: string, value: any)
|
||||
---@param callback_maybe nil|fun(name: string, value: any)
|
||||
function Element:observe_mp_property(name, type_or_callback, callback_maybe)
|
||||
local callback = type(type_or_callback) == 'function' and type_or_callback or callback_maybe
|
||||
local prop_type = type(type_or_callback) == 'string' and type_or_callback or 'native'
|
||||
mp.observe_property(name, prop_type, callback)
|
||||
self:register_disposer(function() mp.unobserve_property(callback) end)
|
||||
end
|
||||
|
||||
-- Adds a keybinding for the lifetime of the element, or until removed manually.
|
||||
---@param key string mpv key identifier.
|
||||
---@param fnFlags fun()|string|table<fun()|string> Callback, or `{callback, flags}` tuple. Callback can be just a method name, in which case it'll be wrapped in `create_action(callback)`.
|
||||
---@param namespace? string Keybinding namespace. Default is `_`.
|
||||
function Element:add_key_binding(key, fnFlags, namespace)
|
||||
local name = self.id .. '-' .. key
|
||||
local isTuple = type(fnFlags) == 'table'
|
||||
local fn = (isTuple and fnFlags[1] or fnFlags)
|
||||
local flags = isTuple and fnFlags[2] or nil
|
||||
namespace = namespace or '_'
|
||||
local names = self._key_bindings[namespace]
|
||||
if not names then
|
||||
names = {}
|
||||
self._key_bindings[namespace] = names
|
||||
end
|
||||
names[name] = true
|
||||
if type(fn) == 'string' then
|
||||
fn = self:create_action(fn)
|
||||
end
|
||||
mp.add_forced_key_binding(key, name, fn, flags)
|
||||
end
|
||||
|
||||
-- Remove all or only keybindings belonging to a specific namespace.
|
||||
---@param namespace? string Optional keybinding namespace to remove.
|
||||
function Element:remove_key_bindings(namespace)
|
||||
local namespaces = namespace and {namespace} or table_keys(self._key_bindings)
|
||||
for _, namespace in ipairs(namespaces) do
|
||||
local names = self._key_bindings[namespace]
|
||||
if names then
|
||||
for name, _ in pairs(names) do
|
||||
mp.remove_key_binding(name)
|
||||
end
|
||||
self._key_bindings[namespace] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Checks if there are any (at all or namespaced) keybindings for this element.
|
||||
---@param namespace? string Only check this namespace.
|
||||
function Element:has_keybindings(namespace)
|
||||
if namespace then
|
||||
return self._key_bindings[namespace] ~= nil
|
||||
else
|
||||
return #table_keys(self._key_bindings) > 0
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if element is not destroyed or otherwise disabled.
|
||||
-- Intended to be overridden by inheriting elements to add more checks.
|
||||
function Element:is_alive() return not self.destroyed end
|
||||
|
||||
-- Wraps a function into a callback that won't run if element is destroyed or otherwise disabled.
|
||||
---@param fn fun(...)|string Function or a name of a method on this class to call.
|
||||
function Element:create_action(fn)
|
||||
if type(fn) == 'string' then
|
||||
local method = fn
|
||||
fn = function(...) self[method](self, ...) end
|
||||
end
|
||||
return function(...)
|
||||
if self:is_alive() then fn(...) end
|
||||
end
|
||||
end
|
||||
|
||||
return Element
|
152
.config/mpv/scripts/uosc/elements/Elements.lua
Normal file
152
.config/mpv/scripts/uosc/elements/Elements.lua
Normal file
@@ -0,0 +1,152 @@
|
||||
local Elements = {_all = {}}
|
||||
|
||||
---@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._all[#self._all + 1] = element
|
||||
self[element.id] = element
|
||||
|
||||
-- Sort by render order
|
||||
table.sort(self._all, function(a, b) return a.render_order < b.render_order end)
|
||||
|
||||
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._all = itable_delete_value(self._all, self[id])
|
||||
self[id] = nil
|
||||
request_render()
|
||||
end
|
||||
end
|
||||
|
||||
function Elements:update_proximities()
|
||||
local curtain_render_order = Elements.curtain.opacity > 0 and Elements.curtain.render_order or 0
|
||||
local mouse_leave_elements = {}
|
||||
local mouse_enter_elements = {}
|
||||
|
||||
-- Calculates proximities for all elements
|
||||
for _, element in self:ipairs() do
|
||||
if element.enabled then
|
||||
local previous_proximity_raw = element.proximity_raw
|
||||
|
||||
-- If curtain is open, we disable all elements set to rendered below it
|
||||
if not element.ignores_curtain and element.render_order < curtain_render_order then
|
||||
element:reset_proximity()
|
||||
else
|
||||
element:update_proximity()
|
||||
end
|
||||
|
||||
if element.proximity_raw == 0 then
|
||||
-- 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
|
||||
|
||||
-- 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].enabled and Elements[id]:get_visibility() ~= 1
|
||||
end)
|
||||
|
||||
self:set_min_visibility(has_invisible and 1 or 0, ids)
|
||||
|
||||
-- Reset proximities when toggling off. Has to happen after `set_min_visibility`,
|
||||
-- as that is using proximity as a tween starting point.
|
||||
if not has_invisible then
|
||||
for _, id in ipairs(ids) do
|
||||
if Elements[id] then Elements[id]:reset_proximity() end
|
||||
end
|
||||
end
|
||||
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
|
||||
local from = math.max(0, element:get_visibility())
|
||||
element:tween_property('min_visibility', from, visibility)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Flash passed elements.
|
||||
---@param ids string[] IDs of elements to peek.
|
||||
function Elements:flash(ids)
|
||||
local elements = itable_filter(self._all, function(element) return itable_has(ids, element.id) end)
|
||||
for _, element in ipairs(elements) do element:flash() end
|
||||
|
||||
-- Special case for 'progress' since it's a state of timeline, not an element
|
||||
if itable_has(ids, 'progress') and not itable_has(ids, 'timeline') then
|
||||
Elements:maybe('timeline', 'flash_progress')
|
||||
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 i = #self._all, 1, -1 do
|
||||
local element = self._all[i]
|
||||
if element.enabled then
|
||||
if element.proximity_raw == 0 then
|
||||
if element:trigger(name, ...) == 'stop_propagation' then break end
|
||||
end
|
||||
if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Returns a property of an element with a passed `id` if it exists, with an optional fallback.
|
||||
---@param id string
|
||||
---@param prop string
|
||||
---@param fallback any
|
||||
function Elements:v(id, prop, fallback)
|
||||
if self[id] and self[id].enabled and self[id][prop] ~= nil then return self[id][prop] end
|
||||
return fallback
|
||||
end
|
||||
|
||||
-- Calls a method on an element with passed `id` if it exists.
|
||||
---@param id string
|
||||
---@param method string
|
||||
function Elements:maybe(id, method, ...)
|
||||
if self[id] then return self[id]:maybe(method, ...) end
|
||||
end
|
||||
|
||||
function Elements:has(id) return self[id] ~= nil end
|
||||
function Elements:ipairs() return ipairs(self._all) end
|
||||
|
||||
return Elements
|
29
.config/mpv/scripts/uosc/elements/ManagedButton.lua
Normal file
29
.config/mpv/scripts/uosc/elements/ManagedButton.lua
Normal file
@@ -0,0 +1,29 @@
|
||||
local Button = require('elements/Button')
|
||||
|
||||
---@alias ManagedButtonProps {name: string; anchor_id?: string; render_order?: number}
|
||||
|
||||
---@class ManagedButton : Button
|
||||
local ManagedButton = class(Button)
|
||||
|
||||
---@param id string
|
||||
---@param props ManagedButtonProps
|
||||
function ManagedButton:new(id, props) return Class.new(self, id, props) --[[@as ManagedButton]] end
|
||||
---@param id string
|
||||
---@param props ManagedButtonProps
|
||||
function ManagedButton:init(id, props)
|
||||
---@type string | table | nil
|
||||
self.command = nil
|
||||
|
||||
Button.init(self, id, table_assign({}, props, {on_click = function() execute_command(self.command) end}))
|
||||
|
||||
self:register_disposer(buttons:subscribe(props.name, function(data) self:update(data) end))
|
||||
end
|
||||
|
||||
function ManagedButton:update(data)
|
||||
for _, prop in ipairs({'icon', 'active', 'badge', 'command', 'tooltip'}) do
|
||||
self[prop] = data[prop]
|
||||
end
|
||||
self.is_clickable = self.command ~= nil
|
||||
end
|
||||
|
||||
return ManagedButton
|
1608
.config/mpv/scripts/uosc/elements/Menu.lua
Normal file
1608
.config/mpv/scripts/uosc/elements/Menu.lua
Normal file
File diff suppressed because it is too large
Load Diff
BIN
.config/mpv/scripts/uosc/elements/Menu.zip
Normal file
BIN
.config/mpv/scripts/uosc/elements/Menu.zip
Normal file
Binary file not shown.
83
.config/mpv/scripts/uosc/elements/PauseIndicator.lua
Normal file
83
.config/mpv/scripts/uosc/elements/PauseIndicator.lua
Normal file
@@ -0,0 +1,83 @@
|
||||
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', {render_order = 3})
|
||||
self.ignores_curtain = true
|
||||
self.paused = state.pause
|
||||
self.opacity = 0
|
||||
self.fadeout = false
|
||||
self:init_options()
|
||||
end
|
||||
|
||||
function PauseIndicator:init_options()
|
||||
self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
|
||||
self.type = options.pause_indicator
|
||||
self:on_prop_pause()
|
||||
end
|
||||
|
||||
function PauseIndicator:flash()
|
||||
-- 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')
|
||||
self.fadeout, self.opacity = false, 1
|
||||
self:tween_property('opacity', 1, 0, 300)
|
||||
end
|
||||
|
||||
-- Decides whether static indicator should be visible or not.
|
||||
function PauseIndicator:decide()
|
||||
self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
|
||||
self.fadeout, self.opacity = self.paused, 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:on_prop_pause()
|
||||
if Elements:v('timeline', 'pressed') then return end
|
||||
if options.pause_indicator == 'flash' then
|
||||
if self.paused ~= state.pause then self:flash() end
|
||||
elseif options.pause_indicator == 'static' then
|
||||
self:decide()
|
||||
end
|
||||
end
|
||||
|
||||
function PauseIndicator:on_options()
|
||||
self:init_options()
|
||||
if self.type == 'flash' then self.opacity = 0 end
|
||||
end
|
||||
|
||||
function PauseIndicator:render()
|
||||
if self.opacity == 0 then return end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Background fadeout
|
||||
if self.fadeout 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) * (self.fadeout 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
|
195
.config/mpv/scripts/uosc/elements/Speed.lua
Normal file
195
.config/mpv/scripts/uosc/elements/Speed.lua
Normal file
@@ -0,0 +1,195 @@
|
||||
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:get_visibility()
|
||||
return Elements:maybe('timeline', 'get_is_hovered') and -1 or Element.get_visibility(self)
|
||||
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:on_options() self:on_coordinates() 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:handle_cursor_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:handle_cursor_up()
|
||||
self.dragging = nil
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Speed:on_global_mouse_leave()
|
||||
self.dragging = nil
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Speed:handle_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
|
||||
function Speed:handle_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
|
||||
|
||||
cursor:zone('primary_down', self, function()
|
||||
self:handle_cursor_down()
|
||||
cursor:once('primary_up', function() self:handle_cursor_up() end)
|
||||
end)
|
||||
cursor:zone('secondary_click', self, function() mp.set_property_native('speed', 1) end)
|
||||
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
|
||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Background
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
||||
color = bg, radius = state.radius, opacity = opacity * config.opacity.speed,
|
||||
})
|
||||
|
||||
-- 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 * state.scale,
|
||||
border_color = bg,
|
||||
opacity = opacity,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Speed
|
483
.config/mpv/scripts/uosc/elements/Timeline.lua
Normal file
483
.config/mpv/scripts/uosc/elements/Timeline.lua
Normal file
@@ -0,0 +1,483 @@
|
||||
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', {render_order = 5})
|
||||
---@type false|{pause: boolean, distance: number, last: {x: number, y: number}}
|
||||
self.pressed = false
|
||||
self.obstructed = false
|
||||
self.size = 0
|
||||
self.progress_size = 0
|
||||
self.min_progress_size = 0 -- used for `flash-progress`
|
||||
self.font_size = 0
|
||||
self.top_border = 0
|
||||
self.line_width = 0
|
||||
self.progress_line_width = 0
|
||||
self.is_hovered = false
|
||||
self.has_thumbnail = false
|
||||
|
||||
self:decide_progress_size()
|
||||
self:update_dimensions()
|
||||
|
||||
-- Release any dragging when file gets unloaded
|
||||
self:register_mp_event('end-file', function() self.pressed = false end)
|
||||
end
|
||||
|
||||
function Timeline:get_visibility()
|
||||
return math.max(Elements:maybe('controls', 'get_visibility') or 0, Element.get_visibility(self))
|
||||
end
|
||||
|
||||
function Timeline:decide_enabled()
|
||||
local previous = self.enabled
|
||||
self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
|
||||
if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
|
||||
end
|
||||
|
||||
function Timeline:get_effective_size()
|
||||
if Elements:v('speed', 'dragging') then return self.size end
|
||||
local progress_size = math.max(self.min_progress_size, self.progress_size)
|
||||
return progress_size + math.ceil((self.size - self.progress_size) * self:get_visibility())
|
||||
end
|
||||
|
||||
function Timeline:get_is_hovered() return self.enabled and self.is_hovered end
|
||||
|
||||
function Timeline:update_dimensions()
|
||||
self.size = round(options.timeline_size * state.scale)
|
||||
self.top_border = round(options.timeline_border * state.scale)
|
||||
self.line_width = round(options.timeline_line_width * state.scale)
|
||||
self.progress_line_width = round(options.progress_line_width * state.scale)
|
||||
self.font_size = math.floor(math.min((self.size + 60 * state.scale) * 0.2, self.size * 0.96) * options.font_scale)
|
||||
local window_border_size = Elements:v('window_border', 'size', 0)
|
||||
self.ax = window_border_size
|
||||
self.ay = display.height - window_border_size - self.size - self.top_border
|
||||
self.bx = display.width - window_border_size
|
||||
self.by = display.height - window_border_size
|
||||
self.width = self.bx - self.ax
|
||||
self.chapter_size = math.max((self.by - self.ay) / 10, 3)
|
||||
self.chapter_size_hover = self.chapter_size * 2
|
||||
|
||||
-- Disable if not enough space
|
||||
local available_space = display.height - window_border_size * 2 - Elements:v('top_bar', 'size', 0)
|
||||
self.obstructed = available_space < self.size + 10
|
||||
self:decide_enabled()
|
||||
end
|
||||
|
||||
function Timeline:decide_progress_size()
|
||||
local show = options.progress == 'always'
|
||||
or (options.progress == 'fullscreen' and state.fullormaxed)
|
||||
or (options.progress == 'windowed' and not state.fullormaxed)
|
||||
self.progress_size = show and options.progress_size or 0
|
||||
end
|
||||
|
||||
function Timeline:toggle_progress()
|
||||
local current = self.progress_size
|
||||
self:tween_property('progress_size', current, current > 0 and 0 or options.progress_size)
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Timeline:flash_progress()
|
||||
if self.enabled and options.flash_duration > 0 then
|
||||
if not self._flash_progress_timer then
|
||||
self._flash_progress_timer = mp.add_timeout(options.flash_duration / 1000, function()
|
||||
self:tween_property('min_progress_size', options.progress_size, 0)
|
||||
end)
|
||||
self._flash_progress_timer:kill()
|
||||
end
|
||||
|
||||
self:tween_stop()
|
||||
self.min_progress_size = options.progress_size
|
||||
request_render()
|
||||
self._flash_progress_timer.timeout = options.flash_duration / 1000
|
||||
self._flash_progress_timer:kill()
|
||||
self._flash_progress_timer:resume()
|
||||
end
|
||||
end
|
||||
|
||||
function Timeline:get_time_at_x(x)
|
||||
local line_width = (options.timeline_style == 'line' and self.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')
|
||||
self.has_thumbnail = false
|
||||
end
|
||||
|
||||
function Timeline:handle_cursor_down()
|
||||
self.pressed = {pause = state.pause, distance = 0, last = {x = cursor.x, y = cursor.y}}
|
||||
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_title_bar() self:update_dimensions() end
|
||||
function Timeline:on_prop_fullormaxed()
|
||||
self:decide_progress_size()
|
||||
self:update_dimensions()
|
||||
end
|
||||
function Timeline:on_display() self:update_dimensions() end
|
||||
function Timeline:on_options()
|
||||
self:decide_progress_size()
|
||||
self:update_dimensions()
|
||||
end
|
||||
function Timeline:handle_cursor_up()
|
||||
if self.pressed then
|
||||
mp.set_property_native('pause', self.pressed.pause)
|
||||
self.pressed = false
|
||||
end
|
||||
end
|
||||
function Timeline:on_global_mouse_leave()
|
||||
self.pressed = false
|
||||
end
|
||||
|
||||
function Timeline:on_global_mouse_move()
|
||||
if self.pressed then
|
||||
self.pressed.distance = self.pressed.distance + get_point_to_point_proximity(self.pressed.last, cursor)
|
||||
self.pressed.last.x, self.pressed.last.y = cursor.x, cursor.y
|
||||
if state.is_video and math.abs(cursor:get_velocity().x) / self.width * state.duration > 30 then
|
||||
self:set_from_cursor(true)
|
||||
else
|
||||
self:set_from_cursor()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Timeline:render()
|
||||
if self.size == 0 then return end
|
||||
|
||||
local size = self:get_effective_size()
|
||||
local visibility = self:get_visibility()
|
||||
self.is_hovered = false
|
||||
|
||||
if size < 1 then
|
||||
if self.has_thumbnail then self:clear_thumbnail() end
|
||||
return
|
||||
end
|
||||
|
||||
if self.proximity_raw == 0 then
|
||||
self.is_hovered = true
|
||||
end
|
||||
if visibility > 0 then
|
||||
cursor:zone('primary_down', self, function()
|
||||
self:handle_cursor_down()
|
||||
cursor:once('primary_up', function() self:handle_cursor_up() end)
|
||||
end)
|
||||
if config.timeline_step ~= 0 then
|
||||
cursor:zone('wheel_down', self, function()
|
||||
mp.commandv('seek', -config.timeline_step, config.timeline_step_flag)
|
||||
end)
|
||||
cursor:zone('wheel_up', self, function()
|
||||
mp.commandv('seek', config.timeline_step, config.timeline_step_flag)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local progress_size = math.max(self.min_progress_size, self.progress_size)
|
||||
|
||||
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches progress_size
|
||||
local hide_text_below = math.max(self.font_size * 0.8, progress_size * 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 tooltip_gap = round(2 * state.scale)
|
||||
local timestamp_gap = tooltip_gap
|
||||
|
||||
local spacing = math.max(math.floor((self.size - 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 - progress_size) / ((self.size - progress_size) / 8), 1)
|
||||
local progress_delta = progress_size > 0 and self.progress_line_width - self.line_width or 0
|
||||
line_width = self.line_width + (progress_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(config.opacity.timeline)
|
||||
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 = config.opacity.position})
|
||||
|
||||
-- Uncached ranges
|
||||
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 options.timeline_cache then
|
||||
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
|
||||
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
|
||||
local hovered_chapter = nil
|
||||
if (config.opacity.chapters > 0 and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)) then
|
||||
local diamond_radius = math.min(math.max(1, foreground_size * 0.8), self.chapter_size)
|
||||
local diamond_radius_hovered = diamond_radius * 2
|
||||
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, radius)
|
||||
local chapter_x, chapter_y = t2x(time), 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(config.opacity.chapters)
|
||||
))
|
||||
ass:draw_start()
|
||||
ass:move_to(chapter_x - radius, chapter_y)
|
||||
ass:line_to(chapter_x, chapter_y - radius)
|
||||
ass:line_to(chapter_x + radius, chapter_y)
|
||||
ass:line_to(chapter_x, chapter_y + radius)
|
||||
ass:draw_stop()
|
||||
end
|
||||
|
||||
if #state.chapters > 0 then
|
||||
-- Find hovered chapter indicator
|
||||
local closest_delta = math.huge
|
||||
|
||||
if self.proximity_raw < diamond_radius_hovered then
|
||||
for i, chapter in ipairs(state.chapters) do
|
||||
local chapter_x, chapter_y = t2x(chapter.time), fay - 1
|
||||
local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
|
||||
if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
|
||||
hovered_chapter, closest_delta = chapter, cursor_chapter_delta
|
||||
self.is_hovered = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i, chapter in ipairs(state.chapters) do
|
||||
if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
|
||||
local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
|
||||
if visibility > 0 then
|
||||
cursor:zone('primary_click', circle, function()
|
||||
mp.commandv('seek', chapter.time, 'absolute+exact')
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Render hovered chapter above others
|
||||
if hovered_chapter then
|
||||
draw_chapter(hovered_chapter.time, diamond_radius_hovered)
|
||||
timestamp_gap = tooltip_gap + round(diamond_radius_hovered)
|
||||
else
|
||||
timestamp_gap = tooltip_gap + round(diamond_radius)
|
||||
end
|
||||
end
|
||||
|
||||
-- A-B loop indicators
|
||||
local has_a, has_b = state.ab_loop_a and state.ab_loop_a >= 0, state.ab_loop_b and state.ab_loop_b > 0
|
||||
local ab_radius = round(math.min(math.max(8, foreground_size * 0.25), foreground_size))
|
||||
|
||||
---@param time number
|
||||
---@param kind 'a'|'b'
|
||||
local function draw_ab_indicator(time, kind)
|
||||
local x = t2x(time)
|
||||
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(config.opacity.chapters)
|
||||
))
|
||||
ass:draw_start()
|
||||
ass:move_to(x, fby - ab_radius)
|
||||
if kind == 'b' then ass:line_to(x + 3, fby - ab_radius) end
|
||||
ass:line_to(x + (kind == 'a' and 0 or ab_radius), fby)
|
||||
ass:line_to(x - (kind == 'b' and 0 or ab_radius), fby)
|
||||
if kind == 'a' then ass:line_to(x - 3, fby - ab_radius) end
|
||||
ass:draw_stop()
|
||||
end
|
||||
|
||||
if has_a then draw_ab_indicator(state.ab_loop_a, 'a') end
|
||||
if has_b then draw_ab_indicator(state.ab_loop_b, 'b') end
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_timeline_timestamp(x, y, align, timestamp, opts)
|
||||
opts.color, opts.border_color = fgt, fg
|
||||
opts.clip = '\\clip(' .. foreground_coordinates .. ')'
|
||||
local func = options.time_precision > 0 and ass.timestamp or ass.txt
|
||||
func(ass, x, y, align, timestamp, opts)
|
||||
opts.color, opts.border_color = bgt, bg
|
||||
opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
|
||||
func(ass, x, y, align, timestamp, opts)
|
||||
end
|
||||
|
||||
-- Time values
|
||||
if text_opacity > 0 then
|
||||
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
|
||||
-- Upcoming cache time
|
||||
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
|
||||
if cache_duration and options.buffered_time_threshold > 0
|
||||
and cache_duration < options.buffered_time_threshold then
|
||||
local margin = 5 * state.scale
|
||||
local x, align = fbx + margin, 4
|
||||
local cache_opts = {
|
||||
size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
|
||||
}
|
||||
local human = round(cache_duration) .. 's'
|
||||
local width = text_width(human, cache_opts)
|
||||
local time_width = timestamp_width(state.time_human, time_opts)
|
||||
local time_width_end = timestamp_width(state.destination_time_human, time_opts)
|
||||
local min_x, max_x = bax + spacing + margin + time_width, bbx - spacing - margin - time_width_end
|
||||
if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
|
||||
draw_timeline_timestamp(x, fcy, align, human, cache_opts)
|
||||
end
|
||||
|
||||
-- Elapsed time
|
||||
if state.time_human then
|
||||
draw_timeline_timestamp(bax + spacing, fcy, 4, state.time_human, time_opts)
|
||||
end
|
||||
|
||||
-- End time
|
||||
if state.destination_time_human then
|
||||
draw_timeline_timestamp(bbx - spacing, fcy, 6, state.destination_time_human, time_opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Hovered time and chapter
|
||||
local rendered_thumbnail = false
|
||||
if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
|
||||
local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x
|
||||
local hovered_seconds = hovered_chapter and hovered_chapter.time or 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.33})
|
||||
local tooltip_anchor = {ax = ax, ay = ay - self.top_border, bx = bx, by = by}
|
||||
|
||||
-- Timestamp
|
||||
local opts = {
|
||||
size = self.font_size, offset = timestamp_gap, margin = tooltip_gap, timestamp = options.time_precision > 0,
|
||||
}
|
||||
local hovered_time_human = format_time(hovered_seconds, state.duration)
|
||||
opts.width_overwrite = timestamp_width(hovered_time_human, opts)
|
||||
tooltip_anchor = ass:tooltip(tooltip_anchor, hovered_time_human, opts)
|
||||
|
||||
-- Thumbnail
|
||||
if not thumbnail.disabled
|
||||
and (not self.pressed or self.pressed.distance < 5)
|
||||
and thumbnail.width ~= 0
|
||||
and thumbnail.height ~= 0
|
||||
then
|
||||
local border = math.ceil(math.max(2, state.radius / 2) * state.scale)
|
||||
local thumb_x_margin, thumb_y_margin = border + tooltip_gap + bax, border + tooltip_gap
|
||||
local thumb_width, thumb_height = thumbnail.width, thumbnail.height
|
||||
local thumb_x = round(clamp(
|
||||
thumb_x_margin,
|
||||
cursor_x - thumb_width / 2,
|
||||
display.width - thumb_width - thumb_x_margin
|
||||
))
|
||||
local thumb_y = round(tooltip_anchor.ay - thumb_y_margin - thumb_height)
|
||||
local ax, ay = (thumb_x - border), (thumb_y - border)
|
||||
local bx, by = (thumb_x + thumb_width + border), (thumb_y + thumb_height + border)
|
||||
ass:rect(ax, ay, bx, by, {
|
||||
color = bg,
|
||||
border = 1,
|
||||
opacity = {main = config.opacity.thumbnail, border = 0.08 * config.opacity.thumbnail},
|
||||
border_color = fg,
|
||||
radius = state.radius,
|
||||
})
|
||||
local thumb_seconds = (state.rebase_start_time == false and state.start_time) and (hovered_seconds - state.start_time) or hovered_seconds
|
||||
mp.commandv('script-message-to', 'thumbfast', 'thumb', thumb_seconds, thumb_x, thumb_y)
|
||||
self.has_thumbnail, rendered_thumbnail = true, true
|
||||
tooltip_anchor.ay = ay
|
||||
end
|
||||
|
||||
-- Chapter title
|
||||
if config.opacity.chapters > 0 and #state.chapters > 0 then
|
||||
local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end,
|
||||
#state.chapters, 1)
|
||||
if chapter and not chapter.is_end_only then
|
||||
ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
|
||||
size = self.font_size,
|
||||
offset = tooltip_gap,
|
||||
responsive = false,
|
||||
bold = true,
|
||||
width_overwrite = chapter.title_wrapped_width * self.font_size,
|
||||
lines = chapter.title_lines,
|
||||
margin = tooltip_gap,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Clear thumbnail
|
||||
if not rendered_thumbnail and self.has_thumbnail then self:clear_thumbnail() end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Timeline
|
335
.config/mpv/scripts/uosc/elements/TopBar.lua
Normal file
335
.config/mpv/scripts/uosc/elements/TopBar.lua
Normal file
@@ -0,0 +1,335 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
|
||||
|
||||
---@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', {render_order = 4})
|
||||
self.size = 0
|
||||
self.icon_size, self.font_size, self.title_by = 1, 1, 1
|
||||
self.show_alt_title = false
|
||||
self.main_title, self.alt_title = nil, nil
|
||||
|
||||
local function maximized_command()
|
||||
if state.platform == 'windows' then
|
||||
mp.command(state.border
|
||||
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
||||
or 'set window-maximized no;cycle fullscreen')
|
||||
else
|
||||
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
|
||||
end
|
||||
end
|
||||
|
||||
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
|
||||
local max = {icon = 'crop_square', command = maximized_command}
|
||||
local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
|
||||
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
|
||||
|
||||
self:decide_titles()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:decide_enabled()
|
||||
if options.top_bar == 'no-border' then
|
||||
self.enabled = not state.border or state.title_bar == false 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 ~= 'no' or state.has_playlist)
|
||||
end
|
||||
|
||||
function TopBar:decide_titles()
|
||||
self.alt_title = state.alt_title ~= '' and state.alt_title or nil
|
||||
self.main_title = state.title ~= '' and state.title or nil
|
||||
|
||||
if (self.main_title == 'No file') then
|
||||
self.main_title = t('No file')
|
||||
end
|
||||
|
||||
-- Fall back to alt title if main is empty
|
||||
if not self.main_title then
|
||||
self.main_title, self.alt_title = self.alt_title, nil
|
||||
end
|
||||
|
||||
-- Deduplicate the main and alt titles by checking if one completely
|
||||
-- contains the other, and using only the longer one.
|
||||
if self.main_title and self.alt_title and not self.show_alt_title then
|
||||
local longer_title, shorter_title
|
||||
if #self.main_title < #self.alt_title then
|
||||
longer_title, shorter_title = self.alt_title, self.main_title
|
||||
else
|
||||
longer_title, shorter_title = self.main_title, self.alt_title
|
||||
end
|
||||
|
||||
local escaped_shorter_title = regexp_escape(shorter_title --[[@as string]])
|
||||
if string.match(longer_title --[[@as string]], escaped_shorter_title) then
|
||||
self.main_title, self.alt_title = longer_title, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TopBar:update_dimensions()
|
||||
self.size = round(options.top_bar_size * state.scale)
|
||||
self.icon_size = round(self.size * 0.5)
|
||||
self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
|
||||
local window_border_size = Elements:v('window_border', 'size', 0)
|
||||
self.ax = window_border_size
|
||||
self.ay = window_border_size
|
||||
self.bx = display.width - window_border_size
|
||||
self.by = self.size + window_border_size
|
||||
end
|
||||
|
||||
function TopBar:toggle_title()
|
||||
if options.top_bar_alt_title_place ~= 'toggle' then return end
|
||||
self.show_alt_title = not self.show_alt_title
|
||||
request_render()
|
||||
end
|
||||
|
||||
function TopBar:on_prop_title() self:decide_titles() end
|
||||
function TopBar:on_prop_alt_title() self:decide_titles() end
|
||||
|
||||
function TopBar:on_prop_border()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:on_prop_title_bar()
|
||||
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_prop_has_playlist()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:on_display() self:update_dimensions() end
|
||||
|
||||
function TopBar:on_options()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
local ax, bx = self.ax, self.bx
|
||||
local margin = math.floor((self.size - self.font_size) / 4)
|
||||
|
||||
-- Window controls
|
||||
if options.top_bar_controls then
|
||||
local is_left, button_ax = options.top_bar_controls == 'left', 0
|
||||
if is_left then
|
||||
button_ax = ax
|
||||
ax = self.size * #self.buttons
|
||||
else
|
||||
button_ax = bx - self.size * #self.buttons
|
||||
bx = button_ax
|
||||
end
|
||||
|
||||
for _, button in ipairs(self.buttons) do
|
||||
local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
|
||||
local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||
local opacity = is_hover and 1 or config.opacity.controls
|
||||
local button_fg = is_hover and (button.hover_fg or bg) or fg
|
||||
local button_bg = is_hover and (button.hover_bg or fg) or bg
|
||||
|
||||
cursor:zone('primary_click', rect, button.command)
|
||||
|
||||
local bg_size = self.size - margin
|
||||
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
|
||||
local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
|
||||
|
||||
ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
|
||||
color = button_bg, opacity = visibility * opacity, radius = state.radius,
|
||||
})
|
||||
|
||||
ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
|
||||
color = button_fg,
|
||||
border_color = button_bg,
|
||||
opacity = visibility,
|
||||
border = options.text_border * state.scale,
|
||||
})
|
||||
|
||||
button_ax = button_ax + self.size
|
||||
end
|
||||
end
|
||||
|
||||
-- Window title
|
||||
if state.title or state.has_playlist then
|
||||
local padding = self.font_size / 2
|
||||
local spacing = 1
|
||||
local left_aligned = options.top_bar_controls == 'left'
|
||||
local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
|
||||
|
||||
-- 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 rect_width = round(text_width(text, opts) + padding * 2)
|
||||
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||
local rect = {
|
||||
ax = ax,
|
||||
ay = title_ay,
|
||||
bx = ax + rect_width,
|
||||
by = self.by - margin,
|
||||
}
|
||||
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||
and 1 or config.opacity.playlist_position
|
||||
if opacity > 0 then
|
||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||
color = fg, opacity = visibility * opacity, radius = state.radius,
|
||||
})
|
||||
end
|
||||
ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
|
||||
if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
|
||||
|
||||
-- Click action
|
||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
|
||||
end
|
||||
|
||||
-- Skip rendering titles if there's not enough horizontal space
|
||||
if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
|
||||
-- Main title
|
||||
local main_title = self.show_alt_title and self.alt_title or self.main_title
|
||||
if main_title then
|
||||
local opts = {
|
||||
size = self.font_size,
|
||||
wrap = 2,
|
||||
color = bgt,
|
||||
opacity = visibility,
|
||||
border = options.text_border * state.scale,
|
||||
border_color = bg,
|
||||
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
|
||||
}
|
||||
local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
|
||||
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
|
||||
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||
local by = self.by - margin
|
||||
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
|
||||
|
||||
if options.top_bar_alt_title_place == 'toggle' then
|
||||
cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
|
||||
end
|
||||
|
||||
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||
local x = align == 6 and title_rect.bx - padding or ax + padding
|
||||
ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
|
||||
title_ay = by + spacing
|
||||
end
|
||||
|
||||
-- Alt title
|
||||
if self.alt_title and options.top_bar_alt_title_place == 'below' then
|
||||
local font_size = self.font_size * 0.9
|
||||
local height = font_size * 1.3
|
||||
local by = title_ay + height
|
||||
local opts = {
|
||||
size = font_size,
|
||||
wrap = 2,
|
||||
color = bgt,
|
||||
border = options.text_border * state.scale,
|
||||
border_color = bg,
|
||||
opacity = visibility,
|
||||
}
|
||||
local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
|
||||
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
|
||||
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||
local bx = ax + rect_width
|
||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
|
||||
ass:rect(ax, title_ay, bx, by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||
local x = align == 6 and bx - padding or ax + padding
|
||||
ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
|
||||
title_ay = by + spacing
|
||||
end
|
||||
|
||||
-- Current chapter
|
||||
if state.current_chapter then
|
||||
local padding_half = round(padding / 2)
|
||||
local font_size = self.font_size * 0.8
|
||||
local height = font_size * 1.3
|
||||
local prefix, postfix = left_aligned and '' or '└ ', left_aligned and ' ┘' or ''
|
||||
local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
|
||||
local next_chapter = state.chapters[state.current_chapter.index + 1]
|
||||
local chapter_end = next_chapter and next_chapter.time or state.duration or 0
|
||||
local remaining_time = ((state.time or 0) - chapter_end) /
|
||||
(options.destination_time == 'time-remaining' and 1 or state.speed)
|
||||
local remaining_human = format_time(remaining_time, math.abs(remaining_time))
|
||||
local opts = {
|
||||
size = font_size,
|
||||
italic = true,
|
||||
wrap = 2,
|
||||
color = bgt,
|
||||
border = options.text_border * state.scale,
|
||||
border_color = bg,
|
||||
opacity = visibility * 0.8,
|
||||
}
|
||||
local remaining_width = timestamp_width(remaining_human, opts)
|
||||
local remaining_box_width = remaining_width + padding_half * 2
|
||||
|
||||
-- Title
|
||||
local max_bx = title_bx - remaining_box_width - spacing
|
||||
local rect_ideal_width = round(text_width(text, opts) + padding * 2)
|
||||
local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
|
||||
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||
local rect = {
|
||||
ax = ax,
|
||||
ay = title_ay,
|
||||
bx = ax + rect_width,
|
||||
by = title_ay + height,
|
||||
}
|
||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
|
||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||
local x = align == 6 and rect.bx - padding or rect.ax + padding
|
||||
ass:txt(x, rect.ay + height / 2, align, text, opts)
|
||||
|
||||
-- Time
|
||||
local time_ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
|
||||
local time_bx = time_ax + remaining_box_width
|
||||
opts.clip = nil
|
||||
ass:rect(time_ax, rect.ay, time_bx, rect.by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
ass:txt(time_ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
|
||||
|
||||
-- Click action
|
||||
rect.bx = time_bx
|
||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
||||
|
||||
title_ay = rect.by + spacing
|
||||
end
|
||||
end
|
||||
self.title_by = title_ay - 1
|
||||
else
|
||||
self.title_by = self.ay
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return TopBar
|
316
.config/mpv/scripts/uosc/elements/Updater.lua
Normal file
316
.config/mpv/scripts/uosc/elements/Updater.lua
Normal file
@@ -0,0 +1,316 @@
|
||||
local Element = require('elements/Element')
|
||||
local dots = {'.', '..', '...'}
|
||||
|
||||
local function cleanup_output(output)
|
||||
return tostring(output):gsub('%c*\n%c*', '\n'):match('^[%s%c]*(.-)[%s%c]*$')
|
||||
end
|
||||
|
||||
---@class Updater : Element
|
||||
local Updater = class(Element)
|
||||
|
||||
function Updater:new() return Class.new(self) --[[@as Updater]] end
|
||||
function Updater:init()
|
||||
Element.init(self, 'updater', {render_order = 1000})
|
||||
self.output = nil
|
||||
self.title = ''
|
||||
self.state = 'circle' -- Also used as an icon name. 'pending' maps to 'spinner'.
|
||||
self.update_available = false
|
||||
|
||||
-- Buttons
|
||||
self.check_button = {method = 'check', title = t('Check for updates')}
|
||||
self.update_button = {method = 'update', title = t('Update uosc'), color = config.color.success}
|
||||
self.changelog_button = {method = 'open_changelog', title = t('Open changelog')}
|
||||
self.close_button = {method = 'destroy', title = t('Close') .. ' (Esc)', color = config.color.error}
|
||||
self.quit_button = {method = 'quit', title = t('Quit')}
|
||||
self.buttons = {self.check_button, self.close_button}
|
||||
self.selected_button_index = 1
|
||||
|
||||
-- Key bindings
|
||||
self:add_key_binding('right', 'select_next_button')
|
||||
self:add_key_binding('tab', 'select_next_button')
|
||||
self:add_key_binding('left', 'select_prev_button')
|
||||
self:add_key_binding('shift+tab', 'select_prev_button')
|
||||
self:add_key_binding('enter', 'activate_selected_button')
|
||||
self:add_key_binding('kp_enter', 'activate_selected_button')
|
||||
self:add_key_binding('esc', 'destroy')
|
||||
|
||||
Elements:maybe('curtain', 'register', self.id)
|
||||
self:check()
|
||||
end
|
||||
|
||||
function Updater:destroy()
|
||||
Elements:maybe('curtain', 'unregister', self.id)
|
||||
Element.destroy(self)
|
||||
end
|
||||
|
||||
function Updater:quit()
|
||||
mp.command('quit')
|
||||
end
|
||||
|
||||
function Updater:select_prev_button()
|
||||
self.selected_button_index = self.selected_button_index - 1
|
||||
if self.selected_button_index < 1 then self.selected_button_index = #self.buttons end
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Updater:select_next_button()
|
||||
self.selected_button_index = self.selected_button_index + 1
|
||||
if self.selected_button_index > #self.buttons then self.selected_button_index = 1 end
|
||||
request_render()
|
||||
end
|
||||
|
||||
function Updater:activate_selected_button()
|
||||
local button = self.buttons[self.selected_button_index]
|
||||
if button then self[button.method](self) end
|
||||
end
|
||||
|
||||
---@param msg string
|
||||
function Updater:append_output(msg)
|
||||
self.output = (self.output or '') .. ass_escape('\n' .. cleanup_output(msg))
|
||||
request_render()
|
||||
end
|
||||
|
||||
---@param msg string
|
||||
function Updater:display_error(msg)
|
||||
self.state = 'error'
|
||||
self.title = t('An error has occurred.') .. ' ' .. t('See console for details.')
|
||||
self:append_output(msg)
|
||||
print(msg)
|
||||
end
|
||||
|
||||
function Updater:open_changelog()
|
||||
if self.state == 'pending' then return end
|
||||
|
||||
local url = 'https://github.com/tomasklaen/uosc/releases'
|
||||
|
||||
self:append_output('Opening URL: ' .. url)
|
||||
|
||||
call_ziggy_async({'open', url}, function(error)
|
||||
if error then
|
||||
self:display_error(error)
|
||||
return
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Updater:check()
|
||||
if self.state == 'pending' then return end
|
||||
self.state = 'pending'
|
||||
self.title = t('Checking for updates') .. '...'
|
||||
|
||||
local url = 'https://api.github.com/repos/tomasklaen/uosc/releases/latest'
|
||||
local headers = utils.format_json({
|
||||
Accept = 'application/vnd.github+json',
|
||||
})
|
||||
local args = {'http-get', '--headers', headers, url}
|
||||
|
||||
self:append_output('Fetching: ' .. url)
|
||||
|
||||
call_ziggy_async(args, function(error, response)
|
||||
if error then
|
||||
self:display_error(error)
|
||||
return
|
||||
end
|
||||
|
||||
release = utils.parse_json(type(response.body) == 'string' and response.body or '')
|
||||
if response.status == 200 and type(release) == 'table' and type(release.tag_name) == 'string' then
|
||||
self.update_available = config.version ~= release.tag_name
|
||||
self:append_output('Response: 200 OK')
|
||||
self:append_output('Current version: ' .. config.version)
|
||||
self:append_output('Latest version: ' .. release.tag_name)
|
||||
if self.update_available then
|
||||
self.state = 'upgrade'
|
||||
self.title = t('Update available')
|
||||
self.buttons = {self.update_button, self.changelog_button, self.close_button}
|
||||
self.selected_button_index = 1
|
||||
else
|
||||
self.state = 'done'
|
||||
self.title = t('Up to date')
|
||||
end
|
||||
else
|
||||
self:display_error('Response couldn\'t be parsed, is invalid, or not-OK status code.\nStatus: ' ..
|
||||
response.status .. '\nBody: ' .. response.body)
|
||||
end
|
||||
|
||||
request_render()
|
||||
end)
|
||||
end
|
||||
|
||||
function Updater:update()
|
||||
if self.state == 'pending' then return end
|
||||
self.state = 'pending'
|
||||
self.title = t('Updating uosc')
|
||||
self.output = nil
|
||||
request_render()
|
||||
|
||||
local config_dir = mp.command_native({'expand-path', '~~/'})
|
||||
|
||||
local function handle_result(success, result, error)
|
||||
if success and result and result.status == 0 then
|
||||
self.state = 'done'
|
||||
self.title = t('uosc has been installed. Restart mpv for it to take effect.')
|
||||
self.buttons = {self.quit_button, self.close_button}
|
||||
self.selected_button_index = 1
|
||||
else
|
||||
self.state = 'error'
|
||||
self.title = t('An error has occurred.') .. ' ' .. t('See above for clues.')
|
||||
end
|
||||
|
||||
local output = (result.stdout or '') .. '\n' .. (error or result.stderr or '')
|
||||
if state.platform == 'darwin' then
|
||||
output =
|
||||
'Self-updater is known not to work on MacOS.\nIf you know about a solution, please make an issue and share it with us!.\n' ..
|
||||
output
|
||||
end
|
||||
self:append_output(output)
|
||||
end
|
||||
|
||||
local function update(args)
|
||||
local env = utils.get_env_list()
|
||||
env[#env + 1] = 'MPV_CONFIG_DIR=' .. config_dir
|
||||
|
||||
mp.command_native_async({
|
||||
name = 'subprocess',
|
||||
capture_stderr = true,
|
||||
capture_stdout = true,
|
||||
playback_only = false,
|
||||
args = args,
|
||||
env = env,
|
||||
}, handle_result)
|
||||
end
|
||||
|
||||
if state.platform == 'windows' then
|
||||
local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/windows.ps1'
|
||||
update({'powershell', '-NoProfile', '-Command', 'irm ' .. url .. ' | iex'})
|
||||
else
|
||||
-- Detect missing dependencies. We can't just let the process run and
|
||||
-- report an error, as on snap packages there's no error. Everything
|
||||
-- either exits with 0, or no helpful output/error message.
|
||||
local missing = {}
|
||||
|
||||
for _, name in ipairs({'curl', 'unzip'}) do
|
||||
local result = mp.command_native({
|
||||
name = 'subprocess',
|
||||
capture_stdout = true,
|
||||
playback_only = false,
|
||||
args = {'which', name},
|
||||
})
|
||||
local path = cleanup_output(result and result.stdout or '')
|
||||
if path == '' then
|
||||
missing[#missing + 1] = name
|
||||
end
|
||||
end
|
||||
|
||||
if #missing > 0 then
|
||||
local stderr = 'Missing dependencies: ' .. table.concat(missing, ', ')
|
||||
if config_dir:match('/snap/') then
|
||||
stderr = stderr ..
|
||||
'\nThis is a known error for mpv snap packages.\nYou can still update uosc by entering the Linux install command from uosc\'s readme into your terminal, it just can\'t be done this way.\nIf you know about a solution, please make an issue and share it with us!'
|
||||
end
|
||||
handle_result(false, {stderr = stderr})
|
||||
else
|
||||
local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/unix.sh'
|
||||
update({'/bin/bash', '-c', 'source <(curl -fsSL ' .. url .. ')'})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Updater:render()
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
local text_size = math.min(20 * state.scale, display.height / 20)
|
||||
local icon_size = text_size * 2
|
||||
local center_x = round(display.width / 2)
|
||||
|
||||
local color = fg
|
||||
if self.state == 'done' or self.update_available then
|
||||
color = config.color.success
|
||||
elseif self.state == 'error' then
|
||||
color = config.color.error
|
||||
end
|
||||
|
||||
-- Divider
|
||||
local divider_width = round(math.min(500 * state.scale, display.width * 0.8))
|
||||
local divider_half, divider_border_half, divider_y = divider_width / 2, round(1 * state.scale), display.height * 0.65
|
||||
local divider_ay, divider_by = round(divider_y - divider_border_half), round(divider_y + divider_border_half)
|
||||
ass:rect(center_x - divider_half, divider_ay, center_x - icon_size, divider_by, {
|
||||
color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
|
||||
})
|
||||
ass:rect(center_x + icon_size, divider_ay, center_x + divider_half, divider_by, {
|
||||
color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
|
||||
})
|
||||
if self.state == 'pending' then
|
||||
ass:spinner(center_x, divider_y, icon_size, {
|
||||
color = fg, border = options.text_border * state.scale, border_color = bg,
|
||||
})
|
||||
else
|
||||
ass:icon(center_x, divider_y, icon_size * 0.8, self.state, {
|
||||
color = color, border = options.text_border * state.scale, border_color = bg,
|
||||
})
|
||||
end
|
||||
|
||||
-- Output
|
||||
local output = self.output or dots[math.ceil((mp.get_time() % 1) * #dots)]
|
||||
ass:txt(center_x, divider_y - icon_size, 2, output, {
|
||||
size = text_size, color = fg, border = options.text_border * state.scale, border_color = bg,
|
||||
})
|
||||
|
||||
-- Title
|
||||
ass:txt(center_x, divider_y + icon_size, 5, self.title, {
|
||||
size = text_size, bold = true, color = color, border = options.text_border * state.scale, border_color = bg,
|
||||
})
|
||||
|
||||
-- Buttons
|
||||
local outline = round(1 * state.scale)
|
||||
local spacing = outline * 9
|
||||
local padding = round(text_size * 0.5)
|
||||
|
||||
local text_opts = {size = text_size, bold = true}
|
||||
|
||||
-- Calculate button text widths
|
||||
local total_width = (#self.buttons - 1) * spacing
|
||||
for _, button in ipairs(self.buttons) do
|
||||
button.width = text_width(button.title, text_opts) + padding * 2
|
||||
total_width = total_width + button.width
|
||||
end
|
||||
|
||||
-- Render buttons
|
||||
local ay = round(divider_y + icon_size * 1.8)
|
||||
local ax = round(display.width / 2 - total_width / 2)
|
||||
local height = text_size + padding * 2
|
||||
for index, button in ipairs(self.buttons) do
|
||||
local rect = {
|
||||
ax = ax,
|
||||
ay = ay,
|
||||
bx = ax + button.width,
|
||||
by = ay + height,
|
||||
}
|
||||
ax = rect.bx + spacing
|
||||
local is_hovered = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||
|
||||
-- Background
|
||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||
color = button.color or fg,
|
||||
radius = state.radius,
|
||||
opacity = is_hovered and 1 or 0.8,
|
||||
})
|
||||
-- Selected outline
|
||||
if index == self.selected_button_index then
|
||||
ass:rect(rect.ax - outline * 4, rect.ay - outline * 4, rect.bx + outline * 4, rect.by + outline * 4, {
|
||||
border = outline,
|
||||
border_color = button.color or fg,
|
||||
radius = state.radius + outline * 4,
|
||||
opacity = {primary = 0, border = 0.5},
|
||||
})
|
||||
end
|
||||
-- Text
|
||||
local x, y = rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2
|
||||
ass:txt(x, y, 5, button.title, {size = text_size, bold = true, color = fgt})
|
||||
|
||||
cursor:zone('primary_click', rect, self:create_action(button.method))
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Updater
|
282
.config/mpv/scripts/uosc/elements/Volume.lua
Normal file
282
.config/mpv/scripts/uosc/elements/Volume.lua
Normal file
@@ -0,0 +1,282 @@
|
||||
local Element = require('elements/Element')
|
||||
|
||||
--[[ 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.border_size = 0
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function VolumeSlider:update_dimensions()
|
||||
self.border_size = math.max(0, round(options.volume_border * state.scale))
|
||||
end
|
||||
|
||||
function VolumeSlider:get_visibility() return Elements.volume:get_visibility(self) 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 - self.border_size) / (self.by - self.ay - self.border_size)
|
||||
self:set_volume(volume_fraction * state.volume_max)
|
||||
end
|
||||
|
||||
function VolumeSlider:on_display() self:update_dimensions() end
|
||||
function VolumeSlider:on_options() self:update_dimensions() 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)
|
||||
end
|
||||
function VolumeSlider:on_global_mouse_move()
|
||||
if self.pressed then self:set_from_cursor() end
|
||||
end
|
||||
function VolumeSlider:handle_wheel_up() self:set_volume(state.volume + options.volume_step) end
|
||||
function VolumeSlider:handle_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
|
||||
|
||||
cursor:zone('primary_down', self, function()
|
||||
self.pressed = true
|
||||
self:set_from_cursor()
|
||||
cursor:once('primary_up', function() self.pressed = false end)
|
||||
end)
|
||||
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
|
||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -math.huge, self.nudge_size
|
||||
local volume_y = self.ay + self.border_size +
|
||||
((height - (self.border_size * 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 r number Border radius.
|
||||
---@param cy? number A y coordinate where to clip the path from the bottom.
|
||||
function create_nudged_path(p, r, cy)
|
||||
cy = cy or ay + p
|
||||
local ax, bx, by = ax + p, bx - p, by - 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, state.radius + self.border_size)
|
||||
local fg_path = create_nudged_path(self.border_size, state.radius, volume_y)
|
||||
|
||||
-- Background
|
||||
ass:new_event()
|
||||
ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg ..
|
||||
'\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}')
|
||||
ass:opacity(config.opacity.slider, 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(config.opacity.slider_gauge, 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(self.border_size, state.radius)
|
||||
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', {render_order = 7})
|
||||
self.size = 0
|
||||
self.mute_ay = 0
|
||||
self.slider = VolumeSlider:new({anchor_id = 'volume', render_order = self.render_order})
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function Volume:destroy()
|
||||
self.slider:destroy()
|
||||
Element.destroy(self)
|
||||
end
|
||||
|
||||
function Volume:get_visibility()
|
||||
return self.slider.pressed and 1 or Elements:maybe('timeline', 'get_is_hovered') and -1
|
||||
or Element.get_visibility(self)
|
||||
end
|
||||
|
||||
function Volume:update_dimensions()
|
||||
self.size = round(options.volume_size * state.scale)
|
||||
local min_y = Elements:v('top_bar', 'by') or Elements:v('window_border', 'size', 0)
|
||||
local max_y = Elements:v('controls', 'ay') or Elements:v('timeline', 'ay')
|
||||
or display.height - Elements:v('window_border', 'size', 0)
|
||||
local available_height = max_y - min_y
|
||||
local max_height = available_height * 0.8
|
||||
local height = round(math.min(self.size * 8, max_height))
|
||||
self.enabled = height > self.size * 2 -- don't render if too small
|
||||
local margin = (self.size / 2) + Elements:v('window_border', 'size', 0)
|
||||
self.ax = round(options.volume == 'left' and margin or display.width - margin - self.size)
|
||||
self.ay = min_y + round((available_height - height) / 2)
|
||||
self.bx = round(self.ax + self.size)
|
||||
self.by = round(self.ay + height)
|
||||
self.mute_ay = self.by - self.size
|
||||
self.slider.enabled = self.enabled
|
||||
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_prop_title_bar() self:update_dimensions() end
|
||||
function Volume:on_controls_reflow() self:update_dimensions() end
|
||||
function Volume:on_options() self:update_dimensions() end
|
||||
|
||||
function Volume:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
|
||||
-- Reset volume on secondary click
|
||||
cursor:zone('secondary_click', self, function()
|
||||
mp.set_property_native('mute', false)
|
||||
mp.set_property_native('volume', 100)
|
||||
end)
|
||||
|
||||
-- Mute button
|
||||
local mute_rect = {ax = self.ax, ay = self.mute_ay, bx = self.bx, by = self.by}
|
||||
cursor:zone('primary_click', mute_rect, function() mp.commandv('cycle', 'mute') end)
|
||||
local ass = assdraw.ass_new()
|
||||
local width_half = (mute_rect.bx - mute_rect.ax) / 2
|
||||
local height_half = (mute_rect.by - mute_rect.ay) / 2
|
||||
local icon_size = math.min(width_half, height_half) * 1.5
|
||||
local icon_name, horizontal_shift = 'volume_up', 0
|
||||
if state.mute then
|
||||
icon_name = 'volume_off'
|
||||
elseif state.volume <= 0 then
|
||||
icon_name, horizontal_shift = 'volume_mute', height_half * 0.25
|
||||
elseif state.volume <= 60 then
|
||||
icon_name, horizontal_shift = 'volume_down', height_half * 0.125
|
||||
end
|
||||
local underlay_opacity = {main = visibility * 0.3, border = visibility}
|
||||
ass:icon(mute_rect.ax + width_half, mute_rect.ay + height_half, icon_size, 'volume_up',
|
||||
{border = options.text_border * state.scale, opacity = underlay_opacity, align = 5}
|
||||
)
|
||||
ass:icon(mute_rect.ax + width_half - horizontal_shift, mute_rect.ay + height_half, icon_size, icon_name,
|
||||
{opacity = visibility, align = 5}
|
||||
)
|
||||
return ass
|
||||
end
|
||||
|
||||
return Volume
|
35
.config/mpv/scripts/uosc/elements/WindowBorder.lua
Normal file
35
.config/mpv/scripts/uosc/elements/WindowBorder.lua
Normal file
@@ -0,0 +1,35 @@
|
||||
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', {render_order = 9999})
|
||||
self.size = 0
|
||||
self:decide_enabled()
|
||||
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 round(options.window_border_size * state.scale) or 0
|
||||
end
|
||||
|
||||
function WindowBorder:on_prop_border() self:decide_enabled() end
|
||||
function WindowBorder:on_prop_title_bar() self:decide_enabled() end
|
||||
function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end
|
||||
function WindowBorder:on_options() 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 = config.opacity.border,
|
||||
})
|
||||
return ass
|
||||
end
|
||||
end
|
||||
|
||||
return WindowBorder
|
Reference in New Issue
Block a user