Add mpv config
This commit is contained in:
parent
5e0be74606
commit
709a064053
106 changed files with 42838 additions and 0 deletions
39
mpv/scripts/uosc/elements/BufferingIndicator.lua
Executable file
39
mpv/scripts/uosc/elements/BufferingIndicator.lua
Executable 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
|
95
mpv/scripts/uosc/elements/Button.lua
Executable file
95
mpv/scripts/uosc/elements/Button.lua
Executable file
|
@ -0,0 +1,95 @@
|
|||
local Element = require('elements/Element')
|
||||
|
||||
---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
|
||||
|
||||
---@class Button : Element
|
||||
local Button = class(Element)
|
||||
|
||||
---@param id string
|
||||
---@param props ButtonProps
|
||||
function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
|
||||
---@param id string
|
||||
---@param props ButtonProps
|
||||
function Button:init(id, props)
|
||||
self.icon = props.icon
|
||||
self.active = props.active
|
||||
self.tooltip = props.tooltip
|
||||
self.badge = props.badge
|
||||
self.foreground = props.foreground or fg
|
||||
self.background = props.background or bg
|
||||
---@type fun()
|
||||
self.on_click = props.on_click
|
||||
Element.init(self, id, props)
|
||||
end
|
||||
|
||||
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
||||
function Button:handle_cursor_click()
|
||||
-- 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_hover = self.proximity_raw == 0
|
||||
local is_hover_or_active = is_hover or self.active
|
||||
local foreground = self.active and self.background or self.foreground
|
||||
local background = self.active and self.foreground or self.background
|
||||
|
||||
-- Background
|
||||
if is_hover_or_active or config.opacity.controls > 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 * (self.active and 1 or (is_hover and 0.3 or config.opacity.controls)),
|
||||
})
|
||||
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
|
374
mpv/scripts/uosc/elements/Controls.lua
Executable file
374
mpv/scripts/uosc/elements/Controls.lua
Executable file
|
@ -0,0 +1,374 @@
|
|||
local Element = require('elements/Element')
|
||||
local Button = require('elements/Button')
|
||||
local CycleButton = require('elements/CycleButton')
|
||||
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'),
|
||||
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 == '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
mpv/scripts/uosc/elements/Curtain.lua
Executable file
35
mpv/scripts/uosc/elements/Curtain.lua
Executable 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
|
59
mpv/scripts/uosc/elements/CycleButton.lua
Executable file
59
mpv/scripts/uosc/elements/CycleButton.lua
Executable file
|
@ -0,0 +1,59 @@
|
|||
local Button = require('elements/Button')
|
||||
|
||||
---@alias CycleState {value: any; icon: string; active?: boolean}
|
||||
---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
|
||||
|
||||
---@class CycleButton : Button
|
||||
local CycleButton = class(Button)
|
||||
|
||||
---@param id string
|
||||
---@param props CycleButtonProps
|
||||
function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
|
||||
---@param id string
|
||||
---@param props CycleButtonProps
|
||||
function CycleButton:init(id, props)
|
||||
local is_state_prop = itable_index_of({'shuffle'}, props.prop)
|
||||
self.prop = props.prop
|
||||
self.states = props.states
|
||||
|
||||
Button.init(self, id, props)
|
||||
|
||||
self.icon = self.states[1].icon
|
||||
self.active = self.states[1].active
|
||||
self.current_state_index = 1
|
||||
self.on_click = function()
|
||||
local new_state = self.states[self.current_state_index + 1] or self.states[1]
|
||||
local new_value = new_state.value
|
||||
if self.owner then
|
||||
mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
|
||||
elseif is_state_prop then
|
||||
if itable_index_of({'yes', 'no'}, new_value) then new_value = new_value == 'yes' end
|
||||
set_state(self.prop, new_value)
|
||||
else
|
||||
mp.set_property(self.prop, new_value)
|
||||
end
|
||||
end
|
||||
|
||||
local function handle_change(name, value)
|
||||
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]
|
||||
self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
|
||||
handle_change(self.prop, external[self.prop])
|
||||
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
|
194
mpv/scripts/uosc/elements/Element.lua
Executable file
194
mpv/scripts/uosc/elements/Element.lua
Executable file
|
@ -0,0 +1,194 @@
|
|||
---@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 = {}
|
||||
|
||||
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', 1, 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
|
||||
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
|
||||
|
||||
return Element
|
152
mpv/scripts/uosc/elements/Elements.lua
Executable file
152
mpv/scripts/uosc/elements/Elements.lua
Executable 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
|
1408
mpv/scripts/uosc/elements/Menu.lua
Executable file
1408
mpv/scripts/uosc/elements/Menu.lua
Executable file
File diff suppressed because it is too large
Load diff
83
mpv/scripts/uosc/elements/PauseIndicator.lua
Executable file
83
mpv/scripts/uosc/elements/PauseIndicator.lua
Executable 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
|
191
mpv/scripts/uosc/elements/Speed.lua
Executable file
191
mpv/scripts/uosc/elements/Speed.lua
Executable file
|
@ -0,0 +1,191 @@
|
|||
local Element = require('elements/Element')
|
||||
|
||||
---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
|
||||
|
||||
---@class Speed : Element
|
||||
local Speed = class(Element)
|
||||
|
||||
---@param props? ElementProps
|
||||
function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
|
||||
function Speed:init(props)
|
||||
Element.init(self, 'speed', props)
|
||||
|
||||
self.width = 0
|
||||
self.height = 0
|
||||
self.notches = 10
|
||||
self.notch_every = 0.1
|
||||
---@type number
|
||||
self.notch_spacing = nil
|
||||
---@type number
|
||||
self.font_size = nil
|
||||
---@type Dragging|nil
|
||||
self.dragging = nil
|
||||
end
|
||||
|
||||
function Speed:on_coordinates()
|
||||
self.height, self.width = self.by - self.ay, self.bx - self.ax
|
||||
self.notch_spacing = self.width / (self.notches + 1)
|
||||
self.font_size = round(self.height * 0.48 * options.font_scale)
|
||||
end
|
||||
function Speed: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
|
481
mpv/scripts/uosc/elements/Timeline.lua
Executable file
481
mpv/scripts/uosc/elements/Timeline.lua
Executable file
|
@ -0,0 +1,481 @@
|
|||
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:handle_wheel_up() mp.commandv('seek', options.timeline_step) end
|
||||
function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) 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)
|
||||
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
|
||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() 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
|
||||
local buffered_playtime = nil
|
||||
if state.uncached_ranges then
|
||||
local opts = {size = 80, anchor_y = fby}
|
||||
local texture_char = visibility > 0 and 'b' or 'a'
|
||||
local offset = opts.size / (visibility > 0 and 24 or 28)
|
||||
for _, range in ipairs(state.uncached_ranges) do
|
||||
if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then
|
||||
buffered_playtime = (range[1] - state.time) / (state.speed or 1)
|
||||
end
|
||||
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_down', 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
|
||||
if buffered_playtime and options.buffered_time_threshold > 0
|
||||
and buffered_playtime < 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(math.max(buffered_playtime, 0)) .. '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,
|
||||
})
|
||||
mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
|
||||
self.has_thumbnail, rendered_thumbnail = true, true
|
||||
tooltip_anchor.ay = ay
|
||||
end
|
||||
|
||||
-- Chapter title
|
||||
if #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
|
349
mpv/scripts/uosc/elements/TopBar.lua
Executable file
349
mpv/scripts/uosc/elements/TopBar.lua
Executable file
|
@ -0,0 +1,349 @@
|
|||
local Element = require('elements/Element')
|
||||
|
||||
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
|
||||
|
||||
---@class TopBarButton : Element
|
||||
local TopBarButton = class(Element)
|
||||
|
||||
---@param id string
|
||||
---@param props TopBarButtonProps
|
||||
function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
|
||||
function TopBarButton:init(id, props)
|
||||
Element.init(self, id, props)
|
||||
self.anchor_id = 'top_bar'
|
||||
self.icon = props.icon
|
||||
self.background = props.background
|
||||
self.command = props.command
|
||||
end
|
||||
|
||||
function TopBarButton:handle_click()
|
||||
mp.command(type(self.command) == 'function' and self.command() or self.command)
|
||||
end
|
||||
|
||||
function TopBarButton:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
local ass = assdraw.ass_new()
|
||||
|
||||
-- Background on hover
|
||||
if self.proximity_raw == 0 then
|
||||
ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
|
||||
end
|
||||
cursor:zone('primary_click', self, function() self:handle_click() end)
|
||||
|
||||
local width, height = self.bx - self.ax, self.by - self.ay
|
||||
local icon_size = math.min(width, height) * 0.5
|
||||
ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
|
||||
opacity = visibility, border = options.text_border * state.scale,
|
||||
})
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
--[[ TopBar ]]
|
||||
|
||||
---@class TopBar : Element
|
||||
local TopBar = class(Element)
|
||||
|
||||
function TopBar:new() return Class.new(self) --[[@as TopBar]] end
|
||||
function TopBar:init()
|
||||
Element.init(self, 'top_bar', {render_order = 4})
|
||||
self.size = 0
|
||||
self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1
|
||||
self.show_alt_title = false
|
||||
self.main_title, self.alt_title = nil, nil
|
||||
|
||||
local function get_maximized_command()
|
||||
if state.platform == 'windows' then
|
||||
return state.border
|
||||
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
||||
or 'set window-maximized no;cycle fullscreen'
|
||||
end
|
||||
return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
|
||||
end
|
||||
|
||||
-- Order aligns from right to left
|
||||
self.buttons = {
|
||||
TopBarButton:new('tb_close', {
|
||||
icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order,
|
||||
}),
|
||||
TopBarButton:new('tb_max', {
|
||||
icon = 'crop_square',
|
||||
background = '222222',
|
||||
command = get_maximized_command,
|
||||
render_order = self.render_order,
|
||||
}),
|
||||
TopBarButton:new('tb_min', {
|
||||
icon = 'minimize',
|
||||
background = '222222',
|
||||
command = 'cycle window-minimized',
|
||||
render_order = self.render_order,
|
||||
}),
|
||||
}
|
||||
|
||||
self:decide_titles()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:destroy()
|
||||
for _, button in ipairs(self.buttons) do button:destroy() end
|
||||
Element.destroy(self)
|
||||
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)
|
||||
for _, element in ipairs(self.buttons) do
|
||||
element.enabled = self.enabled and options.top_bar_controls
|
||||
end
|
||||
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 = string.gsub(shorter_title --[[@as string]], '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
|
||||
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.spacing = math.ceil(self.size * 0.25)
|
||||
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
|
||||
self.button_width = round(self.size * 1.15)
|
||||
local window_border_size = Elements:v('window_border', 'size', 0)
|
||||
self.ay = window_border_size
|
||||
self.bx = display.width - window_border_size
|
||||
self.by = self.size + window_border_size
|
||||
self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
|
||||
self.ax = (options.top_bar_title ~= 'no' or state.has_playlist) and window_border_size or self.title_bx
|
||||
|
||||
local button_bx = self.bx
|
||||
for _, element in pairs(self.buttons) do
|
||||
element.ax, element.bx = button_bx - self.button_width, button_bx
|
||||
element.ay, element.by = self.ay, self.by
|
||||
button_bx = button_bx - self.button_width
|
||||
end
|
||||
end
|
||||
|
||||
function TopBar:toggle_title()
|
||||
if options.top_bar_alt_title_place ~= 'toggle' then return end
|
||||
self.show_alt_title = not self.show_alt_title
|
||||
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()
|
||||
|
||||
-- Window title
|
||||
if state.title or state.has_playlist then
|
||||
local bg_margin = math.floor((self.size - self.font_size) / 4)
|
||||
local padding = self.font_size / 2
|
||||
local spacing = 1
|
||||
local title_ax = self.ax + bg_margin
|
||||
local title_ay = self.ay + bg_margin
|
||||
local max_bx = self.title_bx - self.spacing
|
||||
|
||||
-- Playlist position
|
||||
if state.has_playlist then
|
||||
local text = state.playlist_pos .. '' .. state.playlist_count
|
||||
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
|
||||
.. state.playlist_count
|
||||
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
|
||||
local rect = {
|
||||
ax = title_ax,
|
||||
ay = title_ay,
|
||||
bx = round(title_ax + text_width(text, opts) + padding * 2),
|
||||
by = self.by - bg_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)
|
||||
title_ax = rect.bx + bg_margin
|
||||
|
||||
-- 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 max_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, max_bx, self.by),
|
||||
}
|
||||
local bx = round(math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2))
|
||||
local by = self.by - bg_margin
|
||||
local title_rect = {ax = title_ax, ay = title_ay, bx = bx, 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,
|
||||
})
|
||||
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, 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 bx = round(math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2))
|
||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
|
||||
ass:rect(title_ax, title_ay, bx, by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
ass:txt(title_ax + padding, title_ay + height / 2, 4, 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 text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
|
||||
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 and state.time or 0) - chapter_end
|
||||
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 rect = {
|
||||
ax = title_ax,
|
||||
ay = title_ay,
|
||||
bx = round(math.min(
|
||||
max_bx - remaining_box_width - spacing,
|
||||
title_ax + text_width(text, opts) + padding * 2
|
||||
)),
|
||||
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,
|
||||
})
|
||||
ass:txt(rect.ax + padding, rect.ay + height / 2, 4, text, opts)
|
||||
|
||||
-- Click action
|
||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
||||
|
||||
-- Time
|
||||
rect.ax = rect.bx + spacing
|
||||
rect.bx = rect.ax + remaining_box_width
|
||||
opts.clip = nil
|
||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||
})
|
||||
ass:txt(rect.ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
|
||||
|
||||
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
|
170
mpv/scripts/uosc/elements/Updater.lua
Executable file
170
mpv/scripts/uosc/elements/Updater.lua
Executable file
|
@ -0,0 +1,170 @@
|
|||
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.message = t('Updating uosc')
|
||||
self.state = 'pending' -- Matches icon name
|
||||
local config_dir = mp.command_native({'expand-path', '~~/'})
|
||||
|
||||
Elements:maybe('curtain', 'register', self.id)
|
||||
|
||||
local function handle_result(success, result, error)
|
||||
if success and result and result.status == 0 then
|
||||
self.state = 'done'
|
||||
self.message = t('uosc has been installed. Restart mpv for it to take effect.')
|
||||
else
|
||||
self.state = 'error'
|
||||
self.message = 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.output = ass_escape(cleanup_output(output))
|
||||
|
||||
request_render()
|
||||
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:destroy()
|
||||
Elements:maybe('curtain', 'unregister', self.id)
|
||||
Element.destroy(self)
|
||||
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' 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,
|
||||
})
|
||||
|
||||
-- Message
|
||||
ass:txt(center_x, divider_y + icon_size, 5, self.message, {
|
||||
size = text_size, bold = true, color = color, border = options.text_border * state.scale, border_color = bg,
|
||||
})
|
||||
|
||||
-- Button
|
||||
if self.state ~= 'pending' then
|
||||
-- Background
|
||||
local button_y = divider_y + icon_size * 1.75
|
||||
local button_rect = {
|
||||
ax = round(center_x - icon_size / 2),
|
||||
ay = round(button_y),
|
||||
bx = round(center_x + icon_size / 2),
|
||||
by = round(button_y + icon_size),
|
||||
}
|
||||
local is_hovered = get_point_to_rectangle_proximity(cursor, button_rect) == 0
|
||||
ass:rect(button_rect.ax, button_rect.ay, button_rect.bx, button_rect.by, {
|
||||
color = fg,
|
||||
radius = state.radius,
|
||||
opacity = is_hovered and 1 or 0.5,
|
||||
})
|
||||
|
||||
-- Icon
|
||||
local x = round(button_rect.ax + (button_rect.bx - button_rect.ax) / 2)
|
||||
local y = round(button_rect.ay + (button_rect.by - button_rect.ay) / 2)
|
||||
ass:icon(x, y, icon_size * 0.8, 'close', {color = bg})
|
||||
|
||||
cursor:zone('primary_click', button_rect, function() self:destroy() end)
|
||||
end
|
||||
|
||||
return ass
|
||||
end
|
||||
|
||||
return Updater
|
282
mpv/scripts/uosc/elements/Volume.lua
Executable file
282
mpv/scripts/uosc/elements/Volume.lua
Executable 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
mpv/scripts/uosc/elements/WindowBorder.lua
Executable file
35
mpv/scripts/uosc/elements/WindowBorder.lua
Executable 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
|
Loading…
Add table
Add a link
Reference in a new issue