This commit is contained in:
dichgrem
2025-08-07 10:56:21 +08:00
parent 75eab6943e
commit 62fe668348
55 changed files with 14820 additions and 6 deletions

View 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

View File

@@ -0,0 +1,100 @@
local Element = require('elements/Element')
---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
---@class Button : Element
local Button = class(Element)
---@param id string
---@param props ButtonProps
function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
---@param id string
---@param props ButtonProps
function Button:init(id, props)
self.icon = props.icon
self.active = props.active
self.tooltip = props.tooltip
self.badge = props.badge
self.foreground = props.foreground or fg
self.background = props.background or bg
self.is_clickable = true
---@type fun()|nil
self.on_click = props.on_click
Element.init(self, id, props)
end
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
function Button:handle_cursor_click()
if not self.on_click or not self.is_clickable then return end
-- We delay the callback to next tick, otherwise we are risking race
-- conditions as we are in the middle of event dispatching.
-- For example, handler might add a menu to the end of the element stack, and that
-- than picks up this click event we are in right now, and instantly closes itself.
mp.add_timeout(0.01, self.on_click)
end
function Button:render()
local visibility = self:get_visibility()
if visibility <= 0 then return end
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
local ass = assdraw.ass_new()
local is_clickable = self.is_clickable and self.on_click ~= nil
local is_hover = self.proximity_raw == 0
local foreground = self.active and self.background or self.foreground
local background = self.active and self.foreground or self.background
local background_opacity = self.active and 1 or config.opacity.controls
if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
-- Background
if background_opacity > 0 then
ass:rect(self.ax, self.ay, self.bx, self.by, {
color = (self.active or not is_hover) and background or foreground,
radius = state.radius,
opacity = visibility * background_opacity,
})
end
-- Tooltip on hover
if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
-- Badge
local icon_clip
if self.badge then
local badge_font_size = self.font_size * 0.6
local badge_opts = {size = badge_font_size, color = background, opacity = visibility}
local badge_width = text_width(self.badge, badge_opts)
local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
local bx, by = self.bx - 1, self.by - 1
ass:rect(bx - width, by - height, bx, by, {
color = foreground,
radius = state.radius,
opacity = visibility,
border = self.active and 0 or 1,
border_color = background,
})
ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts)
local clip_border = math.max(self.font_size / 20, 1)
local clip_path = assdraw.ass_new()
clip_path:round_rect_cw(
math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3
)
icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')'
end
-- Icon
local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2)
ass:icon(x, y, self.font_size, self.icon, {
color = foreground,
border = self.active and 0 or options.text_border * state.scale,
border_color = background,
opacity = visibility,
clip = icon_clip,
})
return ass
end
return Button

View File

@@ -0,0 +1,389 @@
local Element = require('elements/Element')
local Button = require('elements/Button')
local CycleButton = require('elements/CycleButton')
local ManagedButton = require('elements/ManagedButton')
local Speed = require('elements/Speed')
-- sizing:
-- static - shrink, have highest claim on available space, disappear when there's not enough of it
-- dynamic - shrink to make room for static elements until they reach their ratio_min, then disappear
-- gap - shrink if there's no space left
-- space - expands to fill available space, shrinks as needed
-- scale - `options.controls_size` scale factor.
-- ratio - Width/height ratio of a static or dynamic element.
-- ratio_min Min ratio for 'dynamic' sized element.
---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic' | 'gap'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table<string, boolean>}
---@class Controls : Element
local Controls = class(Element)
function Controls:new() return Class.new(self) --[[@as Controls]] end
function Controls:init()
Element.init(self, 'controls', {render_order = 6})
---@type ControlItem[] All control elements serialized from `options.controls`.
self.controls = {}
---@type ControlItem[] Only controls that match current dispositions.
self.layout = {}
self:init_options()
end
function Controls:destroy()
self:destroy_elements()
Element.destroy(self)
end
function Controls:init_options()
-- Serialize control elements
local shorthands = {
['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
first = 'command:first_page:script-binding uosc/first?' .. t('First'),
last = 'command:last_page:script-binding uosc/last?' .. t('Last'),
['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?' .. t('Loop playlist'),
['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?' .. t('Loop file'),
shuffle = 'toggle:shuffle:shuffle?' .. t('Shuffle'),
autoload = 'toggle:hdr_auto:autoload@uosc?' .. t('Autoload'),
fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?' .. t('Fullscreen'),
}
-- Parse out disposition/config pairs
local items = {}
local in_disposition = false
local current_item = nil
for c in options.controls:gmatch('.') do
if not current_item then current_item = {disposition = '', config = ''} end
if c == '<' and #current_item.config == 0 then
in_disposition = true
elseif c == '>' and #current_item.config == 0 then
in_disposition = false
elseif c == ',' and not in_disposition then
items[#items + 1] = current_item
current_item = nil
else
local prop = in_disposition and 'disposition' or 'config'
current_item[prop] = current_item[prop] .. c
end
end
items[#items + 1] = current_item
-- Create controls
self.controls = {}
for i, item in ipairs(items) do
local config = shorthands[item.config] and shorthands[item.config] or item.config
local config_tooltip = split(config, ' *%? *')
local tooltip = config_tooltip[2]
config = shorthands[config_tooltip[1]]
and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
local config_badge = split(config, ' *# *')
config = config_badge[1]
local badge = config_badge[2]
local parts = split(config, ' *: *')
local kind, params = parts[1], itable_slice(parts, 2)
-- Serialize dispositions
local dispositions = {}
for _, definition in ipairs(comma_split(item.disposition)) do
if #definition > 0 then
local value = definition:sub(1, 1) ~= '!'
local name = not value and definition:sub(2) or definition
local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
dispositions[prop] = value
end
end
-- Convert toggles into cycles
if kind == 'toggle' then
kind = 'cycle'
params[#params + 1] = 'no/yes!'
end
-- Create a control element
local control = {dispositions = dispositions, kind = kind}
if kind == 'space' then
control.sizing = 'space'
elseif kind == 'gap' then
table_assign(control, {sizing = 'gap', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
elseif kind == 'command' then
if #params ~= 2 then
mp.error(string.format(
'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
))
else
local element = Button:new('control_' .. i, {
render_order = self.render_order,
icon = params[1],
anchor_id = 'controls',
on_click = function() mp.command(params[2]) end,
tooltip = tooltip,
count_prop = 'sub',
})
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
if badge then self:register_badge_updater(badge, element) end
end
elseif kind == 'cycle' then
if #params ~= 3 then
mp.error(string.format(
'cycle button needs 3 parameters, %d received: %s',
#params, table.concat(params, '/')
))
else
local state_configs = split(params[3], ' */ *')
local states = {}
for _, state_config in ipairs(state_configs) do
local active = false
if state_config:sub(-1) == '!' then
active = true
state_config = state_config:sub(1, -2)
end
local state_params = split(state_config, ' *= *')
local value, icon = state_params[1], state_params[2] or params[1]
states[#states + 1] = {value = value, icon = icon, active = active}
end
local element = CycleButton:new('control_' .. i, {
render_order = self.render_order,
prop = params[2],
anchor_id = 'controls',
states = states,
tooltip = tooltip,
})
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
if badge then self:register_badge_updater(badge, element) end
end
elseif kind == 'button' then
if #params ~= 1 then
mp.error(string.format(
'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
))
else
local element = ManagedButton:new('control_' .. i, {
name = params[1],
render_order = self.render_order,
anchor_id = 'controls',
})
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
end
elseif kind == 'speed' then
if not Elements.speed then
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
local scale = tonumber(params[1]) or 1.3
table_assign(control, {
element = element, sizing = 'dynamic', scale = scale, ratio = 3.5, ratio_min = 2,
})
else
msg.error('there can only be 1 speed slider')
end
else
msg.error('unknown element kind "' .. kind .. '"')
break
end
self.controls[#self.controls + 1] = control
end
self:reflow()
end
function Controls:reflow()
-- Populate the layout only with items that match current disposition
self.layout = {}
for _, control in ipairs(self.controls) do
local matches = true
for prop, value in pairs(control.dispositions) do
if state[prop] ~= value then
matches = false
break
end
end
if control.element then control.element.enabled = matches end
if matches then self.layout[#self.layout + 1] = control end
end
self:update_dimensions()
Elements:trigger('controls_reflow')
end
---@param badge string
---@param element Element An element that supports `badge` property.
function Controls:register_badge_updater(badge, element)
local prop_and_limit = split(badge, ' *> *')
local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
local observable_name, serializer, is_external_prop = prop, nil, false
if itable_index_of({'sub', 'audio', 'video'}, prop) then
observable_name = 'track-list'
serializer = function(value)
local count = 0
for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
return count
end
else
local parts = split(prop, '@')
-- Support both new `prop@owner` and old `@prop` syntaxes
if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end
serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
end
local function handler(_, value)
local new_value = serializer(value) --[[@as nil|string|integer]]
local value_number = tonumber(new_value)
if value_number then new_value = value_number > limit and value_number or nil end
element.badge = new_value
request_render()
end
if is_external_prop then
element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end
else
self:observe_mp_property(observable_name, handler)
end
end
function Controls:get_visibility()
return Elements:v('speed', 'dragging') and 1 or Elements:maybe('timeline', 'get_is_hovered')
and -1 or Element.get_visibility(self)
end
function Controls:update_dimensions()
local window_border = Elements:v('window_border', 'size', 0)
local size = round(options.controls_size * state.scale)
local spacing = round(options.controls_spacing * state.scale)
local margin = round(options.controls_margin * state.scale)
-- Disable when not enough space
local available_space = display.height - window_border * 2 - Elements:v('top_bar', 'size', 0)
- Elements:v('timeline', 'size', 0)
self.enabled = available_space > size + 10
-- Reset hide/enabled flags
for c, control in ipairs(self.layout) do
control.hide = false
if control.element then control.element.enabled = self.enabled end
end
if not self.enabled then return end
-- Container
self.bx = display.width - window_border - margin
self.by = Elements:v('timeline', 'ay', display.height - window_border) - margin
self.ax, self.ay = window_border + margin, self.by - size
-- Controls
local available_width, statics_width = self.bx - self.ax, 0
local min_content_width = statics_width
local max_dynamics_width, dynamic_units, spaces, gaps = 0, 0, 0, 0
-- Calculate statics_width, min_content_width, and count spaces & gaps
for c, control in ipairs(self.layout) do
if control.sizing == 'space' then
spaces = spaces + 1
elseif control.sizing == 'gap' then
gaps = gaps + control.scale * control.ratio
elseif control.sizing == 'static' then
local width = size * control.scale * control.ratio + (c ~= #self.layout and spacing or 0)
statics_width = statics_width + width
min_content_width = min_content_width + width
elseif control.sizing == 'dynamic' then
local spacing = (c ~= #self.layout and spacing or 0)
statics_width = statics_width + spacing
min_content_width = min_content_width + size * control.scale * control.ratio_min + spacing
max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
dynamic_units = dynamic_units + control.scale * control.ratio
end
end
-- Hide & disable elements in the middle until we fit into available width
if min_content_width > available_width then
local i = math.ceil(#self.layout / 2 + 0.1)
for a = 0, #self.layout - 1, 1 do
i = i + (a * (a % 2 == 0 and 1 or -1))
local control = self.layout[i]
if control.sizing ~= 'gap' and control.sizing ~= 'space' then
control.hide = true
if control.element then control.element.enabled = false end
if control.sizing == 'static' then
local width = size * control.scale * control.ratio
min_content_width = min_content_width - width - spacing
statics_width = statics_width - width - spacing
elseif control.sizing == 'dynamic' then
statics_width = statics_width - spacing
min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
dynamic_units = dynamic_units - control.scale * control.ratio
end
if min_content_width < available_width then break end
end
end
end
-- Lay out the elements
local current_x = self.ax
local width_for_dynamics = available_width - statics_width
local empty_space_width = width_for_dynamics - max_dynamics_width
local width_for_gaps = math.min(empty_space_width, size * gaps)
local individual_space_width = spaces > 0 and ((empty_space_width - width_for_gaps) / spaces) or 0
for c, control in ipairs(self.layout) do
if not control.hide then
local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
local width, height = 0, 0
if sizing == 'space' then
if individual_space_width > 0 then width = individual_space_width end
elseif sizing == 'gap' then
if width_for_gaps > 0 then width = width_for_gaps * (ratio / gaps) end
elseif sizing == 'static' then
height = size * scale
width = height * ratio
elseif sizing == 'dynamic' then
height = size * scale
width = max_dynamics_width < width_for_dynamics
and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
end
local bx = current_x + width
if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
current_x = element and bx + spacing or bx
end
end
Elements:update_proximities()
request_render()
end
function Controls:on_dispositions() self:reflow() end
function Controls:on_display() self:update_dimensions() end
function Controls:on_prop_border() self:update_dimensions() end
function Controls:on_prop_title_bar() self:update_dimensions() end
function Controls:on_prop_fullormaxed() self:update_dimensions() end
function Controls:on_timeline_enabled() self:update_dimensions() end
function Controls:destroy_elements()
for _, control in ipairs(self.controls) do
if control.element then control.element:destroy() end
end
end
function Controls:on_options()
self:destroy_elements()
self:init_options()
end
return Controls

View 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

View File

@@ -0,0 +1,86 @@
local Button = require('elements/Button')
---@alias CycleState {value: any; icon: string; active?: boolean}
---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
local function yes_no_to_boolean(value)
if type(value) ~= 'string' then return value end
local lowercase = trim(value):lower()
if lowercase == 'yes' or lowercase == 'no' then
return lowercase == 'yes'
else
return value
end
end
---@class CycleButton : Button
local CycleButton = class(Button)
---@param id string
---@param props CycleButtonProps
function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
---@param id string
---@param props CycleButtonProps
function CycleButton:init(id, props)
local is_state_prop = itable_index_of({'shuffle'}, props.prop)
self.prop = props.prop
self.states = props.states
Button.init(self, id, props)
self.icon = self.states[1].icon
self.active = self.states[1].active
self.current_state_index = 1
self.on_click = function()
local new_state = self.states[self.current_state_index + 1] or self.states[1]
local new_value = new_state.value
if self.owner == 'uosc' then
if type(options[self.prop]) == 'number' then
options[self.prop] = tonumber(new_value) or 0
else
options[self.prop] = yes_no_to_boolean(new_value)
end
handle_options({[self.prop] = options[self.prop]})
elseif self.owner then
mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
elseif is_state_prop then
set_state(self.prop, yes_no_to_boolean(new_value))
else
mp.set_property(self.prop, new_value)
end
end
local function handle_change(name, value)
-- Removes unnecessary floating point digits from values like `2.00000`.
-- This happens when observing properties like `speed`.
if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
value = tonumber(value)
end
value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
local index = itable_find(self.states, function(state) return state.value == value end)
self.current_state_index = index or 1
self.icon = self.states[self.current_state_index].icon
self.active = self.states[self.current_state_index].active
request_render()
end
local prop_parts = split(self.prop, '@')
if #prop_parts == 2 then -- External prop with a script owner
self.prop, self.owner = prop_parts[1], prop_parts[2]
if self.owner == 'uosc' then
self['on_options'] = function() handle_change(self.prop, options[self.prop]) end
handle_change(self.prop, options[self.prop])
else
self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
handle_change(self.prop, external[self.prop])
end
elseif is_state_prop then -- uosc's state props
self['on_prop_' .. self.prop] = function(self, value) handle_change(self.prop, value) end
handle_change(self.prop, state[self.prop])
else
self:observe_mp_property(self.prop, 'string', handle_change)
end
end
return CycleButton

View File

@@ -0,0 +1,260 @@
---@alias ElementProps {enabled?: boolean; render_order?: number; ax?: number; ay?: number; bx?: number; by?: number; ignores_curtain?: boolean; anchor_id?: string;}
-- Base class all elements inherit from.
---@class Element : Class
local Element = class()
---@param id string
---@param props? ElementProps
function Element:init(id, props)
self.id = id
self.render_order = 1
-- `false` means element won't be rendered, or receive events
self.enabled = true
-- Element coordinates
self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0
-- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range.
self.proximity = 0
-- Raw proximity in pixels.
self.proximity_raw = math.huge
---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility.
self.min_visibility = 0
---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations
self.forced_visibility = nil
---@type boolean Show this element even when curtain is visible.
self.ignores_curtain = false
---@type nil|string ID of an element from which this one should inherit visibility.
self.anchor_id = nil
---@type fun()[] Disposer functions called when element is destroyed.
self._disposers = {}
---@type table<string,table<string, boolean>> Namespaced active key bindings. Default namespace is `_`.
self._key_bindings = {}
if props then table_assign(self, props) end
-- Flash timer
self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
local function getTo() return self.proximity end
local function onTweenEnd() self.forced_visibility = nil end
if self.enabled then
self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
else
onTweenEnd()
end
end)
self._flash_out_timer:kill()
Elements:add(self)
end
function Element:destroy()
for _, disposer in ipairs(self._disposers) do disposer() end
self.destroyed = true
self:remove_key_bindings()
Elements:remove(self)
end
function Element:reset_proximity() self.proximity, self.proximity_raw = 0, math.huge end
---@param ax number
---@param ay number
---@param bx number
---@param by number
function Element:set_coordinates(ax, ay, bx, by)
self.ax, self.ay, self.bx, self.by = ax, ay, bx, by
Elements:update_proximities()
self:maybe('on_coordinates')
end
function Element:update_proximity()
if cursor.hidden then
self:reset_proximity()
else
local range = options.proximity_out - options.proximity_in
self.proximity_raw = get_point_to_rectangle_proximity(cursor, self)
self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range)
end
end
function Element:is_persistent()
local persist = config[self.id .. '_persistency']
return persist and (
(persist.audio and state.is_audio)
or (
persist.paused and state.pause
and (not Elements.timeline or not Elements.timeline.pressed or Elements.timeline.pressed.pause)
)
or (persist.video and state.is_video)
or (persist.image and state.is_image)
or (persist.idle and state.is_idle)
or (persist.windowed and not state.fullormaxed)
or (persist.fullscreen and state.fullormaxed)
)
end
-- Decide elements visibility based on proximity and various other factors
function Element:get_visibility()
-- Hide when curtain is visible, unless this elements ignores it
local min_order = (Elements.curtain.opacity > 0 and not self.ignores_curtain) and Elements.curtain.render_order or 0
if self.render_order < min_order then return 0 end
-- Persistency
if self:is_persistent() then return 1 end
-- Forced visibility
if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end
-- Anchor inheritance
-- If anchor returns -1, it means all attached elements should force hide.
local anchor = self.anchor_id and Elements[self.anchor_id]
local anchor_visibility = anchor and anchor:get_visibility() or 0
return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility)
end
-- Call method if it exists
function Element:maybe(name, ...)
if self[name] then return self[name](self, ...) end
end
-- Attach a tweening animation to this element
---@param from number
---@param to number|fun():number
---@param setter fun(value: number)
---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
---@param callback? fun() Called either on animation end, or when animation is killed.
function Element:tween(from, to, setter, duration_or_callback, callback)
self:tween_stop()
self._kill_tween = self.enabled and tween(
from, to, setter, duration_or_callback,
function()
self._kill_tween = nil
if callback then callback() end
end
)
end
function Element:is_tweening() return self and self._kill_tween end
function Element:tween_stop() self:maybe('_kill_tween') end
-- Animate an element property between 2 values.
---@param prop string
---@param from number
---@param to number|fun():number
---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
---@param callback? fun() Called either on animation end, or when animation is killed.
function Element:tween_property(prop, from, to, duration_or_callback, callback)
self:tween(from, to, function(value) self[prop] = value end, duration_or_callback, callback)
end
---@param name string
function Element:trigger(name, ...)
local result = self:maybe('on_' .. name, ...)
request_render()
return result
end
-- Briefly flashes the element for `options.flash_duration` milliseconds.
-- Useful to visualize changes of volume and timeline when changed via hotkeys.
function Element:flash()
if self.enabled and options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
self:tween_stop()
self.forced_visibility = 1
request_render()
self._flash_out_timer.timeout = options.flash_duration / 1000
self._flash_out_timer:kill()
self._flash_out_timer:resume()
end
end
-- Register disposer to be called when element is destroyed.
---@param disposer fun()
function Element:register_disposer(disposer)
if not itable_index_of(self._disposers, disposer) then
self._disposers[#self._disposers + 1] = disposer
end
end
-- Automatically registers disposer for the passed callback.
---@param event string
---@param callback fun()
function Element:register_mp_event(event, callback)
mp.register_event(event, callback)
self:register_disposer(function() mp.unregister_event(callback) end)
end
-- Automatically registers disposer for the observer.
---@param name string
---@param type_or_callback string|fun(name: string, value: any)
---@param callback_maybe nil|fun(name: string, value: any)
function Element:observe_mp_property(name, type_or_callback, callback_maybe)
local callback = type(type_or_callback) == 'function' and type_or_callback or callback_maybe
local prop_type = type(type_or_callback) == 'string' and type_or_callback or 'native'
mp.observe_property(name, prop_type, callback)
self:register_disposer(function() mp.unobserve_property(callback) end)
end
-- Adds a keybinding for the lifetime of the element, or until removed manually.
---@param key string mpv key identifier.
---@param fnFlags fun()|string|table<fun()|string> Callback, or `{callback, flags}` tuple. Callback can be just a method name, in which case it'll be wrapped in `create_action(callback)`.
---@param namespace? string Keybinding namespace. Default is `_`.
function Element:add_key_binding(key, fnFlags, namespace)
local name = self.id .. '-' .. key
local isTuple = type(fnFlags) == 'table'
local fn = (isTuple and fnFlags[1] or fnFlags)
local flags = isTuple and fnFlags[2] or nil
namespace = namespace or '_'
local names = self._key_bindings[namespace]
if not names then
names = {}
self._key_bindings[namespace] = names
end
names[name] = true
if type(fn) == 'string' then
fn = self:create_action(fn)
end
mp.add_forced_key_binding(key, name, fn, flags)
end
-- Remove all or only keybindings belonging to a specific namespace.
---@param namespace? string Optional keybinding namespace to remove.
function Element:remove_key_bindings(namespace)
local namespaces = namespace and {namespace} or table_keys(self._key_bindings)
for _, namespace in ipairs(namespaces) do
local names = self._key_bindings[namespace]
if names then
for name, _ in pairs(names) do
mp.remove_key_binding(name)
end
self._key_bindings[namespace] = nil
end
end
end
-- Checks if there are any (at all or namespaced) keybindings for this element.
---@param namespace? string Only check this namespace.
function Element:has_keybindings(namespace)
if namespace then
return self._key_bindings[namespace] ~= nil
else
return #table_keys(self._key_bindings) > 0
end
end
-- Check if element is not destroyed or otherwise disabled.
-- Intended to be overridden by inheriting elements to add more checks.
function Element:is_alive() return not self.destroyed end
-- Wraps a function into a callback that won't run if element is destroyed or otherwise disabled.
---@param fn fun(...)|string Function or a name of a method on this class to call.
function Element:create_action(fn)
if type(fn) == 'string' then
local method = fn
fn = function(...) self[method](self, ...) end
end
return function(...)
if self:is_alive() then fn(...) end
end
end
return Element

View 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

View File

@@ -0,0 +1,29 @@
local Button = require('elements/Button')
---@alias ManagedButtonProps {name: string; anchor_id?: string; render_order?: number}
---@class ManagedButton : Button
local ManagedButton = class(Button)
---@param id string
---@param props ManagedButtonProps
function ManagedButton:new(id, props) return Class.new(self, id, props) --[[@as ManagedButton]] end
---@param id string
---@param props ManagedButtonProps
function ManagedButton:init(id, props)
---@type string | table | nil
self.command = nil
Button.init(self, id, table_assign({}, props, {on_click = function() execute_command(self.command) end}))
self:register_disposer(buttons:subscribe(props.name, function(data) self:update(data) end))
end
function ManagedButton:update(data)
for _, prop in ipairs({'icon', 'active', 'badge', 'command', 'tooltip'}) do
self[prop] = data[prop]
end
self.is_clickable = self.command ~= nil
end
return ManagedButton

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -0,0 +1,195 @@
local Element = require('elements/Element')
---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
---@class Speed : Element
local Speed = class(Element)
---@param props? ElementProps
function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
function Speed:init(props)
Element.init(self, 'speed', props)
self.width = 0
self.height = 0
self.notches = 10
self.notch_every = 0.1
---@type number
self.notch_spacing = nil
---@type number
self.font_size = nil
---@type Dragging|nil
self.dragging = nil
end
function Speed:get_visibility()
return Elements:maybe('timeline', 'get_is_hovered') and -1 or Element.get_visibility(self)
end
function Speed:on_coordinates()
self.height, self.width = self.by - self.ay, self.bx - self.ax
self.notch_spacing = self.width / (self.notches + 1)
self.font_size = round(self.height * 0.48 * options.font_scale)
end
function Speed:on_options() self:on_coordinates() end
function Speed:speed_step(speed, up)
if options.speed_step_is_factor then
if up then
return speed * options.speed_step
else
return speed * 1 / options.speed_step
end
else
if up then
return speed + options.speed_step
else
return speed - options.speed_step
end
end
end
function Speed:handle_cursor_down()
self:tween_stop() -- Stop and cleanup possible ongoing animations
self.dragging = {
start_time = mp.get_time(),
start_x = cursor.x,
distance = 0,
speed_distance = 0,
start_speed = state.speed,
}
end
function Speed:on_global_mouse_move()
if not self.dragging then return end
self.dragging.distance = cursor.x - self.dragging.start_x
self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every)
local speed_current = state.speed
local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance
speed_drag_current = clamp(0.01, speed_drag_current, 100)
local drag_dir_up = speed_drag_current > speed_current
local speed_step_next = speed_current
local speed_drag_diff = math.abs(speed_drag_current - speed_current)
while math.abs(speed_step_next - speed_current) < speed_drag_diff do
speed_step_next = self:speed_step(speed_step_next, drag_dir_up)
end
local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up)
local speed_new = speed_step_prev
local speed_next_diff = math.abs(speed_drag_current - speed_step_next)
local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev)
if speed_next_diff < speed_prev_diff then
speed_new = speed_step_next
end
if speed_new ~= speed_current then
mp.set_property_native('speed', speed_new)
end
end
function Speed:handle_cursor_up()
self.dragging = nil
request_render()
end
function Speed:on_global_mouse_leave()
self.dragging = nil
request_render()
end
function Speed:handle_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
function Speed:handle_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end
function Speed:render()
local visibility = self:get_visibility()
local opacity = self.dragging and 1 or visibility
if opacity <= 0 then return end
cursor:zone('primary_down', self, function()
self:handle_cursor_down()
cursor:once('primary_up', function() self:handle_cursor_up() end)
end)
cursor:zone('secondary_click', self, function() mp.set_property_native('speed', 1) end)
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
local ass = assdraw.ass_new()
-- Background
ass:rect(self.ax, self.ay, self.bx, self.by, {
color = bg, radius = state.radius, opacity = opacity * config.opacity.speed,
})
-- Coordinates
local ax, ay = self.ax, self.ay
local bx, by = self.bx, ay + self.height
local half_width = (self.width / 2)
local half_x = ax + half_width
-- Notches
local speed_at_center = state.speed
if self.dragging then
speed_at_center = self.dragging.start_speed + self.dragging.speed_distance
speed_at_center = clamp(0.01, speed_at_center, 100)
end
local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every
local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing)
local guide_size = math.floor(self.height / 7.5)
local notch_by = by - guide_size
local notch_ay_big = ay + round(self.font_size * 1.1)
local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
local from_to_index = math.floor(self.notches / 2)
for i = -from_to_index, from_to_index do
local notch_speed = nearest_notch_speed + (i * self.notch_every)
if notch_speed >= 0 and notch_speed <= 100 then
local notch_x = nearest_notch_x + (i * self.notch_spacing)
local notch_thickness = 1
local notch_ay = notch_ay_small
if (notch_speed % (self.notch_every * 10)) < 0.00000001 then
notch_ay = notch_ay_big
notch_thickness = 1.5
elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then
notch_ay = notch_ay_medium
end
ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, {
color = fg,
border = 1,
border_color = bg,
opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity,
})
end
end
-- Center guide
ass:new_event()
ass:append('{\\rDefault\\an7\\blur0\\bord1\\shad0\\1c&H' .. fg .. '\\3c&H' .. bg .. '}')
ass:opacity(opacity)
ass:pos(0, 0)
ass:draw_start()
ass:move_to(half_x, by - 2 - guide_size)
ass:line_to(half_x + guide_size, by - 2)
ass:line_to(half_x - guide_size, by - 2)
ass:draw_stop()
-- Speed value
local speed_text = (round(state.speed * 100) / 100) .. 'x'
ass:txt(half_x, ay + (notch_ay_big - ay) / 2, 5, speed_text, {
size = self.font_size,
color = bgt,
border = options.text_border * state.scale,
border_color = bg,
opacity = opacity,
})
return ass
end
return Speed

View File

@@ -0,0 +1,483 @@
local Element = require('elements/Element')
---@class Timeline : Element
local Timeline = class(Element)
function Timeline:new() return Class.new(self) --[[@as Timeline]] end
function Timeline:init()
Element.init(self, 'timeline', {render_order = 5})
---@type false|{pause: boolean, distance: number, last: {x: number, y: number}}
self.pressed = false
self.obstructed = false
self.size = 0
self.progress_size = 0
self.min_progress_size = 0 -- used for `flash-progress`
self.font_size = 0
self.top_border = 0
self.line_width = 0
self.progress_line_width = 0
self.is_hovered = false
self.has_thumbnail = false
self:decide_progress_size()
self:update_dimensions()
-- Release any dragging when file gets unloaded
self:register_mp_event('end-file', function() self.pressed = false end)
end
function Timeline:get_visibility()
return math.max(Elements:maybe('controls', 'get_visibility') or 0, Element.get_visibility(self))
end
function Timeline:decide_enabled()
local previous = self.enabled
self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
end
function Timeline:get_effective_size()
if Elements:v('speed', 'dragging') then return self.size end
local progress_size = math.max(self.min_progress_size, self.progress_size)
return progress_size + math.ceil((self.size - self.progress_size) * self:get_visibility())
end
function Timeline:get_is_hovered() return self.enabled and self.is_hovered end
function Timeline:update_dimensions()
self.size = round(options.timeline_size * state.scale)
self.top_border = round(options.timeline_border * state.scale)
self.line_width = round(options.timeline_line_width * state.scale)
self.progress_line_width = round(options.progress_line_width * state.scale)
self.font_size = math.floor(math.min((self.size + 60 * state.scale) * 0.2, self.size * 0.96) * options.font_scale)
local window_border_size = Elements:v('window_border', 'size', 0)
self.ax = window_border_size
self.ay = display.height - window_border_size - self.size - self.top_border
self.bx = display.width - window_border_size
self.by = display.height - window_border_size
self.width = self.bx - self.ax
self.chapter_size = math.max((self.by - self.ay) / 10, 3)
self.chapter_size_hover = self.chapter_size * 2
-- Disable if not enough space
local available_space = display.height - window_border_size * 2 - Elements:v('top_bar', 'size', 0)
self.obstructed = available_space < self.size + 10
self:decide_enabled()
end
function Timeline:decide_progress_size()
local show = options.progress == 'always'
or (options.progress == 'fullscreen' and state.fullormaxed)
or (options.progress == 'windowed' and not state.fullormaxed)
self.progress_size = show and options.progress_size or 0
end
function Timeline:toggle_progress()
local current = self.progress_size
self:tween_property('progress_size', current, current > 0 and 0 or options.progress_size)
request_render()
end
function Timeline:flash_progress()
if self.enabled and options.flash_duration > 0 then
if not self._flash_progress_timer then
self._flash_progress_timer = mp.add_timeout(options.flash_duration / 1000, function()
self:tween_property('min_progress_size', options.progress_size, 0)
end)
self._flash_progress_timer:kill()
end
self:tween_stop()
self.min_progress_size = options.progress_size
request_render()
self._flash_progress_timer.timeout = options.flash_duration / 1000
self._flash_progress_timer:kill()
self._flash_progress_timer:resume()
end
end
function Timeline:get_time_at_x(x)
local line_width = (options.timeline_style == 'line' and self.line_width - 1 or 0)
local time_width = self.width - line_width - 1
local fax = (time_width) * state.time / state.duration
local fbx = fax + line_width
-- time starts 0.5 pixels in
x = x - self.ax - 0.5
if x > fbx then
x = x - line_width
elseif x > fax then
x = fax
end
local progress = clamp(0, x / time_width, 1)
return state.duration * progress
end
---@param fast? boolean
function Timeline:set_from_cursor(fast)
if state.time and state.duration then
mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
end
end
function Timeline:clear_thumbnail()
mp.commandv('script-message-to', 'thumbfast', 'clear')
self.has_thumbnail = false
end
function Timeline:handle_cursor_down()
self.pressed = {pause = state.pause, distance = 0, last = {x = cursor.x, y = cursor.y}}
mp.set_property_native('pause', true)
self:set_from_cursor()
end
function Timeline:on_prop_duration() self:decide_enabled() end
function Timeline:on_prop_time() self:decide_enabled() end
function Timeline:on_prop_border() self:update_dimensions() end
function Timeline:on_prop_title_bar() self:update_dimensions() end
function Timeline:on_prop_fullormaxed()
self:decide_progress_size()
self:update_dimensions()
end
function Timeline:on_display() self:update_dimensions() end
function Timeline:on_options()
self:decide_progress_size()
self:update_dimensions()
end
function Timeline:handle_cursor_up()
if self.pressed then
mp.set_property_native('pause', self.pressed.pause)
self.pressed = false
end
end
function Timeline:on_global_mouse_leave()
self.pressed = false
end
function Timeline:on_global_mouse_move()
if self.pressed then
self.pressed.distance = self.pressed.distance + get_point_to_point_proximity(self.pressed.last, cursor)
self.pressed.last.x, self.pressed.last.y = cursor.x, cursor.y
if state.is_video and math.abs(cursor:get_velocity().x) / self.width * state.duration > 30 then
self:set_from_cursor(true)
else
self:set_from_cursor()
end
end
end
function Timeline:render()
if self.size == 0 then return end
local size = self:get_effective_size()
local visibility = self:get_visibility()
self.is_hovered = false
if size < 1 then
if self.has_thumbnail then self:clear_thumbnail() end
return
end
if self.proximity_raw == 0 then
self.is_hovered = true
end
if visibility > 0 then
cursor:zone('primary_down', self, function()
self:handle_cursor_down()
cursor:once('primary_up', function() self:handle_cursor_up() end)
end)
if config.timeline_step ~= 0 then
cursor:zone('wheel_down', self, function()
mp.commandv('seek', -config.timeline_step, config.timeline_step_flag)
end)
cursor:zone('wheel_up', self, function()
mp.commandv('seek', config.timeline_step, config.timeline_step_flag)
end)
end
end
local ass = assdraw.ass_new()
local progress_size = math.max(self.min_progress_size, self.progress_size)
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches progress_size
local hide_text_below = math.max(self.font_size * 0.8, progress_size * 2)
local hide_text_ramp = hide_text_below / 2
local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
local tooltip_gap = round(2 * state.scale)
local timestamp_gap = tooltip_gap
local spacing = math.max(math.floor((self.size - self.font_size) / 2.5), 4)
local progress = state.time / state.duration
local is_line = options.timeline_style == 'line'
-- Foreground & Background bar coordinates
local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
local fcy = fay + (size / 2)
local line_width = 0
if is_line then
local minimized_fraction = 1 - math.min((size - progress_size) / ((self.size - progress_size) / 8), 1)
local progress_delta = progress_size > 0 and self.progress_line_width - self.line_width or 0
line_width = self.line_width + (progress_delta * minimized_fraction)
fax = bax + (self.width - line_width) * progress
fbx = fax + line_width
line_width = line_width - 1
else
fax, fbx = bax, bax + self.width * progress
end
local foreground_size = fby - fay
local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
-- time starts 0.5 pixels in
local time_ax = bax + 0.5
local time_width = self.width - line_width - 1
-- time to x: calculates x coordinate so that it never lies inside of the line
local function t2x(time)
local x = time_ax + time_width * time / state.duration
return time <= state.time and x or x + line_width
end
-- Background
ass:new_event()
ass:pos(0, 0)
ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
ass:opacity(config.opacity.timeline)
ass:draw_start()
ass:rect_cw(bax, bay, fax, bby) --left of progress
ass:rect_cw(fbx, bay, bbx, bby) --right of progress
ass:rect_cw(fax, bay, fbx, fay) --above progress
ass:draw_stop()
-- Progress
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
-- Uncached ranges
if state.uncached_ranges then
local opts = {size = 80, anchor_y = fby}
local texture_char = visibility > 0 and 'b' or 'a'
local offset = opts.size / (visibility > 0 and 24 or 28)
for _, range in ipairs(state.uncached_ranges) do
if options.timeline_cache then
local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
ass:texture(ax, fay, bx, fby, texture_char, opts)
opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
ass:texture(ax, fay, bx, fby, texture_char, opts)
end
end
end
-- Custom ranges
for _, chapter_range in ipairs(state.chapter_ranges) do
local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
or t2x(math.min(chapter_range['end'], state.duration))
ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
end
-- Chapters
local hovered_chapter = nil
if (config.opacity.chapters > 0 and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)) then
local diamond_radius = math.min(math.max(1, foreground_size * 0.8), self.chapter_size)
local diamond_radius_hovered = diamond_radius * 2
local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
if diamond_radius > 0 then
local function draw_chapter(time, radius)
local chapter_x, chapter_y = t2x(time), fay - 1
ass:new_event()
ass:append(string.format(
'{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
))
ass:draw_start()
ass:move_to(chapter_x - radius, chapter_y)
ass:line_to(chapter_x, chapter_y - radius)
ass:line_to(chapter_x + radius, chapter_y)
ass:line_to(chapter_x, chapter_y + radius)
ass:draw_stop()
end
if #state.chapters > 0 then
-- Find hovered chapter indicator
local closest_delta = math.huge
if self.proximity_raw < diamond_radius_hovered then
for i, chapter in ipairs(state.chapters) do
local chapter_x, chapter_y = t2x(chapter.time), fay - 1
local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
hovered_chapter, closest_delta = chapter, cursor_chapter_delta
self.is_hovered = true
end
end
end
for i, chapter in ipairs(state.chapters) do
if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
if visibility > 0 then
cursor:zone('primary_click', circle, function()
mp.commandv('seek', chapter.time, 'absolute+exact')
end)
end
end
-- Render hovered chapter above others
if hovered_chapter then
draw_chapter(hovered_chapter.time, diamond_radius_hovered)
timestamp_gap = tooltip_gap + round(diamond_radius_hovered)
else
timestamp_gap = tooltip_gap + round(diamond_radius)
end
end
-- A-B loop indicators
local has_a, has_b = state.ab_loop_a and state.ab_loop_a >= 0, state.ab_loop_b and state.ab_loop_b > 0
local ab_radius = round(math.min(math.max(8, foreground_size * 0.25), foreground_size))
---@param time number
---@param kind 'a'|'b'
local function draw_ab_indicator(time, kind)
local x = t2x(time)
ass:new_event()
ass:append(string.format(
'{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
))
ass:draw_start()
ass:move_to(x, fby - ab_radius)
if kind == 'b' then ass:line_to(x + 3, fby - ab_radius) end
ass:line_to(x + (kind == 'a' and 0 or ab_radius), fby)
ass:line_to(x - (kind == 'b' and 0 or ab_radius), fby)
if kind == 'a' then ass:line_to(x - 3, fby - ab_radius) end
ass:draw_stop()
end
if has_a then draw_ab_indicator(state.ab_loop_a, 'a') end
if has_b then draw_ab_indicator(state.ab_loop_b, 'b') end
end
end
local function draw_timeline_timestamp(x, y, align, timestamp, opts)
opts.color, opts.border_color = fgt, fg
opts.clip = '\\clip(' .. foreground_coordinates .. ')'
local func = options.time_precision > 0 and ass.timestamp or ass.txt
func(ass, x, y, align, timestamp, opts)
opts.color, opts.border_color = bgt, bg
opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
func(ass, x, y, align, timestamp, opts)
end
-- Time values
if text_opacity > 0 then
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
-- Upcoming cache time
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
if cache_duration and options.buffered_time_threshold > 0
and cache_duration < options.buffered_time_threshold then
local margin = 5 * state.scale
local x, align = fbx + margin, 4
local cache_opts = {
size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
}
local human = round(cache_duration) .. 's'
local width = text_width(human, cache_opts)
local time_width = timestamp_width(state.time_human, time_opts)
local time_width_end = timestamp_width(state.destination_time_human, time_opts)
local min_x, max_x = bax + spacing + margin + time_width, bbx - spacing - margin - time_width_end
if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
draw_timeline_timestamp(x, fcy, align, human, cache_opts)
end
-- Elapsed time
if state.time_human then
draw_timeline_timestamp(bax + spacing, fcy, 4, state.time_human, time_opts)
end
-- End time
if state.destination_time_human then
draw_timeline_timestamp(bbx - spacing, fcy, 6, state.destination_time_human, time_opts)
end
end
-- Hovered time and chapter
local rendered_thumbnail = false
if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x
local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x)
-- Cursor line
-- 0.5 to switch when the pixel is half filled in
local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg
local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby
ass:rect(ax, ay, bx, by, {color = color, opacity = 0.33})
local tooltip_anchor = {ax = ax, ay = ay - self.top_border, bx = bx, by = by}
-- Timestamp
local opts = {
size = self.font_size, offset = timestamp_gap, margin = tooltip_gap, timestamp = options.time_precision > 0,
}
local hovered_time_human = format_time(hovered_seconds, state.duration)
opts.width_overwrite = timestamp_width(hovered_time_human, opts)
tooltip_anchor = ass:tooltip(tooltip_anchor, hovered_time_human, opts)
-- Thumbnail
if not thumbnail.disabled
and (not self.pressed or self.pressed.distance < 5)
and thumbnail.width ~= 0
and thumbnail.height ~= 0
then
local border = math.ceil(math.max(2, state.radius / 2) * state.scale)
local thumb_x_margin, thumb_y_margin = border + tooltip_gap + bax, border + tooltip_gap
local thumb_width, thumb_height = thumbnail.width, thumbnail.height
local thumb_x = round(clamp(
thumb_x_margin,
cursor_x - thumb_width / 2,
display.width - thumb_width - thumb_x_margin
))
local thumb_y = round(tooltip_anchor.ay - thumb_y_margin - thumb_height)
local ax, ay = (thumb_x - border), (thumb_y - border)
local bx, by = (thumb_x + thumb_width + border), (thumb_y + thumb_height + border)
ass:rect(ax, ay, bx, by, {
color = bg,
border = 1,
opacity = {main = config.opacity.thumbnail, border = 0.08 * config.opacity.thumbnail},
border_color = fg,
radius = state.radius,
})
local thumb_seconds = (state.rebase_start_time == false and state.start_time) and (hovered_seconds - state.start_time) or hovered_seconds
mp.commandv('script-message-to', 'thumbfast', 'thumb', thumb_seconds, thumb_x, thumb_y)
self.has_thumbnail, rendered_thumbnail = true, true
tooltip_anchor.ay = ay
end
-- Chapter title
if config.opacity.chapters > 0 and #state.chapters > 0 then
local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end,
#state.chapters, 1)
if chapter and not chapter.is_end_only then
ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
size = self.font_size,
offset = tooltip_gap,
responsive = false,
bold = true,
width_overwrite = chapter.title_wrapped_width * self.font_size,
lines = chapter.title_lines,
margin = tooltip_gap,
})
end
end
end
-- Clear thumbnail
if not rendered_thumbnail and self.has_thumbnail then self:clear_thumbnail() end
return ass
end
return Timeline

View File

@@ -0,0 +1,335 @@
local Element = require('elements/Element')
---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
---@class TopBar : Element
local TopBar = class(Element)
function TopBar:new() return Class.new(self) --[[@as TopBar]] end
function TopBar:init()
Element.init(self, 'top_bar', {render_order = 4})
self.size = 0
self.icon_size, self.font_size, self.title_by = 1, 1, 1
self.show_alt_title = false
self.main_title, self.alt_title = nil, nil
local function maximized_command()
if state.platform == 'windows' then
mp.command(state.border
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
or 'set window-maximized no;cycle fullscreen')
else
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
end
end
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
local max = {icon = 'crop_square', command = maximized_command}
local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
self:decide_titles()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:decide_enabled()
if options.top_bar == 'no-border' then
self.enabled = not state.border or state.title_bar == false or state.fullscreen
else
self.enabled = options.top_bar == 'always'
end
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
end
function TopBar:decide_titles()
self.alt_title = state.alt_title ~= '' and state.alt_title or nil
self.main_title = state.title ~= '' and state.title or nil
if (self.main_title == 'No file') then
self.main_title = t('No file')
end
-- Fall back to alt title if main is empty
if not self.main_title then
self.main_title, self.alt_title = self.alt_title, nil
end
-- Deduplicate the main and alt titles by checking if one completely
-- contains the other, and using only the longer one.
if self.main_title and self.alt_title and not self.show_alt_title then
local longer_title, shorter_title
if #self.main_title < #self.alt_title then
longer_title, shorter_title = self.alt_title, self.main_title
else
longer_title, shorter_title = self.main_title, self.alt_title
end
local escaped_shorter_title = regexp_escape(shorter_title --[[@as string]])
if string.match(longer_title --[[@as string]], escaped_shorter_title) then
self.main_title, self.alt_title = longer_title, nil
end
end
end
function TopBar:update_dimensions()
self.size = round(options.top_bar_size * state.scale)
self.icon_size = round(self.size * 0.5)
self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
local window_border_size = Elements:v('window_border', 'size', 0)
self.ax = window_border_size
self.ay = window_border_size
self.bx = display.width - window_border_size
self.by = self.size + window_border_size
end
function TopBar:toggle_title()
if options.top_bar_alt_title_place ~= 'toggle' then return end
self.show_alt_title = not self.show_alt_title
request_render()
end
function TopBar:on_prop_title() self:decide_titles() end
function TopBar:on_prop_alt_title() self:decide_titles() end
function TopBar:on_prop_border()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:on_prop_title_bar()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:on_prop_fullscreen()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:on_prop_maximized()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:on_prop_has_playlist()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:on_display() self:update_dimensions() end
function TopBar:on_options()
self:decide_enabled()
self:update_dimensions()
end
function TopBar:render()
local visibility = self:get_visibility()
if visibility <= 0 then return end
local ass = assdraw.ass_new()
local ax, bx = self.ax, self.bx
local margin = math.floor((self.size - self.font_size) / 4)
-- Window controls
if options.top_bar_controls then
local is_left, button_ax = options.top_bar_controls == 'left', 0
if is_left then
button_ax = ax
ax = self.size * #self.buttons
else
button_ax = bx - self.size * #self.buttons
bx = button_ax
end
for _, button in ipairs(self.buttons) do
local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
local opacity = is_hover and 1 or config.opacity.controls
local button_fg = is_hover and (button.hover_fg or bg) or fg
local button_bg = is_hover and (button.hover_bg or fg) or bg
cursor:zone('primary_click', rect, button.command)
local bg_size = self.size - margin
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
color = button_bg, opacity = visibility * opacity, radius = state.radius,
})
ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
color = button_fg,
border_color = button_bg,
opacity = visibility,
border = options.text_border * state.scale,
})
button_ax = button_ax + self.size
end
end
-- Window title
if state.title or state.has_playlist then
local padding = self.font_size / 2
local spacing = 1
local left_aligned = options.top_bar_controls == 'left'
local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
-- Playlist position
if state.has_playlist then
local text = state.playlist_pos .. '' .. state.playlist_count
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
.. state.playlist_count
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
local rect_width = round(text_width(text, opts) + padding * 2)
local ax = left_aligned and title_bx - rect_width or title_ax
local rect = {
ax = ax,
ay = title_ay,
bx = ax + rect_width,
by = self.by - margin,
}
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
and 1 or config.opacity.playlist_position
if opacity > 0 then
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
color = fg, opacity = visibility * opacity, radius = state.radius,
})
end
ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
-- Click action
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
end
-- Skip rendering titles if there's not enough horizontal space
if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
-- Main title
local main_title = self.show_alt_title and self.alt_title or self.main_title
if main_title then
local opts = {
size = self.font_size,
wrap = 2,
color = bgt,
opacity = visibility,
border = options.text_border * state.scale,
border_color = bg,
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
}
local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
local ax = left_aligned and title_bx - rect_width or title_ax
local by = self.by - margin
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
if options.top_bar_alt_title_place == 'toggle' then
cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
end
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
})
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and title_rect.bx - padding or ax + padding
ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
title_ay = by + spacing
end
-- Alt title
if self.alt_title and options.top_bar_alt_title_place == 'below' then
local font_size = self.font_size * 0.9
local height = font_size * 1.3
local by = title_ay + height
local opts = {
size = font_size,
wrap = 2,
color = bgt,
border = options.text_border * state.scale,
border_color = bg,
opacity = visibility,
}
local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
local ax = left_aligned and title_bx - rect_width or title_ax
local bx = ax + rect_width
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
ass:rect(ax, title_ay, bx, by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
})
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and bx - padding or ax + padding
ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
title_ay = by + spacing
end
-- Current chapter
if state.current_chapter then
local padding_half = round(padding / 2)
local font_size = self.font_size * 0.8
local height = font_size * 1.3
local prefix, postfix = left_aligned and '' or '', left_aligned and '' or ''
local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
local next_chapter = state.chapters[state.current_chapter.index + 1]
local chapter_end = next_chapter and next_chapter.time or state.duration or 0
local remaining_time = ((state.time or 0) - chapter_end) /
(options.destination_time == 'time-remaining' and 1 or state.speed)
local remaining_human = format_time(remaining_time, math.abs(remaining_time))
local opts = {
size = font_size,
italic = true,
wrap = 2,
color = bgt,
border = options.text_border * state.scale,
border_color = bg,
opacity = visibility * 0.8,
}
local remaining_width = timestamp_width(remaining_human, opts)
local remaining_box_width = remaining_width + padding_half * 2
-- Title
local max_bx = title_bx - remaining_box_width - spacing
local rect_ideal_width = round(text_width(text, opts) + padding * 2)
local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
local ax = left_aligned and title_bx - rect_width or title_ax
local rect = {
ax = ax,
ay = title_ay,
bx = ax + rect_width,
by = title_ay + height,
}
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
})
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and rect.bx - padding or rect.ax + padding
ass:txt(x, rect.ay + height / 2, align, text, opts)
-- Time
local time_ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
local time_bx = time_ax + remaining_box_width
opts.clip = nil
ass:rect(time_ax, rect.ay, time_bx, rect.by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
})
ass:txt(time_ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
-- Click action
rect.bx = time_bx
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
title_ay = rect.by + spacing
end
end
self.title_by = title_ay - 1
else
self.title_by = self.ay
end
return ass
end
return TopBar

View 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

View 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