
This is not complete, as I currently can't get the formatter to recognize `.editorconfig` file. Or at least it seems to ignore most of the options in it, so a lot of formatting doesn't adhere to the config. Will need another pass when that gets fixed.
3956 lines
129 KiB
Lua
3956 lines
129 KiB
Lua
--[[
|
||
uosc 3.0.0 - 2022-Aug-22 | https://github.com/tomasklaen/uosc
|
||
Minimalist cursor proximity based UI for MPV player.
|
||
]]
|
||
|
||
if mp.get_property('osc') == 'yes' then
|
||
mp.msg.info('Disabled because original osc is enabled!')
|
||
return
|
||
end
|
||
|
||
local assdraw = require('mp.assdraw')
|
||
local opt = require('mp.options')
|
||
local utils = require('mp.utils')
|
||
local msg = require('mp.msg')
|
||
local osd = mp.create_osd_overlay('ass-events')
|
||
local infinity = 1e309
|
||
|
||
-- OPTIONS/CONFIG/STATE
|
||
local options = {
|
||
timeline_style = 'line',
|
||
timeline_line_width = 2,
|
||
timeline_line_width_fullscreen = 3,
|
||
timeline_line_width_minimized_scale = 10,
|
||
timeline_size_min = 2,
|
||
timeline_size_max = 40,
|
||
timeline_size_min_fullscreen = 0,
|
||
timeline_size_max_fullscreen = 60,
|
||
timeline_start_hidden = false,
|
||
timeline_persistency = '',
|
||
timeline_opacity = 0.9,
|
||
timeline_border = 1,
|
||
timeline_step = 5,
|
||
timeline_cached_ranges = '4e845c:0.5',
|
||
timeline_font_scale = 1,
|
||
timeline_chapters = 'dots',
|
||
timeline_chapters_opacity = 0.2,
|
||
timeline_chapters_width = 6,
|
||
|
||
volume = 'right',
|
||
volume_size = 40,
|
||
volume_size_fullscreen = 60,
|
||
volume_persistency = '',
|
||
volume_opacity = 0.8,
|
||
volume_border = 1,
|
||
volume_step = 1,
|
||
volume_font_scale = 1,
|
||
|
||
speed = false,
|
||
speed_size = 46,
|
||
speed_size_fullscreen = 60,
|
||
speed_persistency = '',
|
||
speed_opacity = 1,
|
||
speed_step = 0.1,
|
||
speed_step_is_factor = false,
|
||
speed_font_scale = 1,
|
||
|
||
menu_item_height = 36,
|
||
menu_item_height_fullscreen = 50,
|
||
menu_min_width = 260,
|
||
menu_min_width_fullscreen = 360,
|
||
menu_wasd_navigation = false,
|
||
menu_hjkl_navigation = false,
|
||
menu_opacity = 0.8,
|
||
menu_parent_opacity = 0.4,
|
||
menu_font_scale = 1,
|
||
|
||
menu_button = 'never',
|
||
menu_button_size = 26,
|
||
menu_button_size_fullscreen = 30,
|
||
menu_button_opacity = 1,
|
||
menu_button_persistency = '',
|
||
menu_button_border = 1,
|
||
|
||
top_bar = 'no-border',
|
||
top_bar_size = 40,
|
||
top_bar_size_fullscreen = 46,
|
||
top_bar_persistency = '',
|
||
top_bar_controls = true,
|
||
top_bar_title = true,
|
||
|
||
window_border_size = 1,
|
||
window_border_opacity = 0.8,
|
||
|
||
ui_scale = 1,
|
||
pause_on_click_shorter_than = 0,
|
||
flash_duration = 1000,
|
||
proximity_in = 40,
|
||
proximity_out = 120,
|
||
color_foreground = 'ffffff',
|
||
color_foreground_text = '000000',
|
||
color_background = '000000',
|
||
color_background_text = 'ffffff',
|
||
total_time = false,
|
||
time_precision = 0,
|
||
font_bold = false,
|
||
autohide = false,
|
||
pause_indicator = 'flash',
|
||
curtain_opacity = 0.5,
|
||
stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
|
||
directory_navigation_loops = false,
|
||
media_types = '3gp,asf,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv',
|
||
subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt',
|
||
font_height_to_letter_width_ratio = 0.5,
|
||
default_directory = '~/',
|
||
chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end',
|
||
}
|
||
opt.read_options(options, 'uosc')
|
||
local config = {
|
||
-- sets max rendering frequency in case the
|
||
-- native rendering frequency could not be detected
|
||
render_delay = 1 / 60,
|
||
font = mp.get_property('options/osd-font'),
|
||
}
|
||
local bold_tag = options.font_bold and '\\b1' or ''
|
||
local display = {
|
||
width = 1280,
|
||
height = 720,
|
||
aspect = 1.77778,
|
||
}
|
||
local cursor = {
|
||
hidden = true, -- true when autohidden or outside of the player window
|
||
x = 0,
|
||
y = 0,
|
||
}
|
||
local state = {
|
||
os = (function()
|
||
if os.getenv('windir') ~= nil then return 'windows' end
|
||
local homedir = os.getenv('HOME')
|
||
if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'macos' end
|
||
return 'linux'
|
||
end)(),
|
||
cwd = mp.get_property('working-directory'),
|
||
media_title = '',
|
||
time = nil, -- current media playback time
|
||
duration = nil, -- current media duration
|
||
time_human = nil, -- current playback time in human format
|
||
duration_or_remaining_time_human = nil, -- depends on options.total_time
|
||
pause = mp.get_property_native('pause'),
|
||
chapters = nil,
|
||
chapter_ranges = nil,
|
||
border = mp.get_property_native('border'),
|
||
fullscreen = mp.get_property_native('fullscreen'),
|
||
maximized = mp.get_property_native('window-maximized'),
|
||
fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'),
|
||
render_timer = nil,
|
||
render_last_time = 0,
|
||
volume = nil,
|
||
volume_max = nil,
|
||
mute = nil,
|
||
is_audio = nil, -- true if file is audio only (mp3, etc)
|
||
is_image = nil,
|
||
has_audio = nil,
|
||
has_video = nil,
|
||
cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function()
|
||
if not options.autohide then return end
|
||
handle_mouse_leave()
|
||
end),
|
||
mouse_bindings_enabled = false,
|
||
cached_ranges = nil,
|
||
render_delay = config.render_delay,
|
||
}
|
||
local forced_key_bindings -- defined at the bottom next to events
|
||
|
||
-- HELPERS
|
||
|
||
function round(number)
|
||
local modulus = number % 1
|
||
return modulus < 0.5 and math.floor(number) or math.ceil(number)
|
||
end
|
||
|
||
function call_me_maybe(fn, value1, value2, value3)
|
||
if fn then fn(value1, value2, value3) end
|
||
end
|
||
|
||
function split(str, pattern)
|
||
local list = {}
|
||
local full_pattern = '(.-)' .. pattern
|
||
local last_end = 1
|
||
local start_index, end_index, capture = str:find(full_pattern, 1)
|
||
while start_index do
|
||
list[#list + 1] = capture
|
||
last_end = end_index + 1
|
||
start_index, end_index, capture = str:find(full_pattern, last_end)
|
||
end
|
||
if last_end <= (#str + 1) then
|
||
capture = str:sub(last_end)
|
||
list[#list + 1] = capture
|
||
end
|
||
return list
|
||
end
|
||
|
||
function itable_find(haystack, needle)
|
||
local is_needle = type(needle) == 'function' and needle or function(index, value)
|
||
return value == needle
|
||
end
|
||
for index, value in ipairs(haystack) do
|
||
if is_needle(index, value) then return index, value end
|
||
end
|
||
end
|
||
|
||
function itable_filter(haystack, needle)
|
||
local is_needle = type(needle) == 'function' and needle or function(index, value)
|
||
return value == needle
|
||
end
|
||
local filtered = {}
|
||
for index, value in ipairs(haystack) do
|
||
if is_needle(index, value) then filtered[#filtered + 1] = value end
|
||
end
|
||
return filtered
|
||
end
|
||
|
||
function itable_remove(haystack, needle)
|
||
local should_remove = type(needle) == 'function' and needle or function(value)
|
||
return value == needle
|
||
end
|
||
local new_table = {}
|
||
for _, value in ipairs(haystack) do
|
||
if not should_remove(value) then
|
||
new_table[#new_table + 1] = value
|
||
end
|
||
end
|
||
return new_table
|
||
end
|
||
|
||
function itable_slice(haystack, start_pos, end_pos)
|
||
start_pos = start_pos and start_pos or 1
|
||
end_pos = end_pos and end_pos or #haystack
|
||
|
||
if end_pos < 0 then end_pos = #haystack + end_pos + 1 end
|
||
if start_pos < 0 then start_pos = #haystack + start_pos + 1 end
|
||
|
||
local new_table = {}
|
||
for index, value in ipairs(haystack) do
|
||
if index >= start_pos and index <= end_pos then
|
||
new_table[#new_table + 1] = value
|
||
end
|
||
end
|
||
return new_table
|
||
end
|
||
|
||
function table_copy(table)
|
||
local new_table = {}
|
||
for key, value in pairs(table) do new_table[key] = value end
|
||
return new_table
|
||
end
|
||
|
||
-- Sorting comparator close to (but not exactly) how file explorers sort files
|
||
local word_order_comparator = (function()
|
||
local symbol_order
|
||
local default_order
|
||
|
||
if state.os == 'win' then
|
||
symbol_order = {
|
||
['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7,
|
||
['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14,
|
||
['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20,
|
||
}
|
||
default_order = 21
|
||
else
|
||
symbol_order = {
|
||
['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8,
|
||
['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14,
|
||
['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23,
|
||
}
|
||
default_order = 21
|
||
end
|
||
|
||
return function(a, b)
|
||
a = a:lower()
|
||
b = b:lower()
|
||
for i = 1, math.max(#a, #b) do
|
||
local ai = a:sub(i, i)
|
||
local bi = b:sub(i, i)
|
||
if ai == nil and bi then return true end
|
||
if bi == nil and ai then return false end
|
||
local a_order = symbol_order[ai] or default_order
|
||
local b_order = symbol_order[bi] or default_order
|
||
if a_order == b_order then
|
||
return a < b
|
||
else
|
||
return a_order < b_order
|
||
end
|
||
end
|
||
end
|
||
end)()
|
||
|
||
-- Creates in-between frames to animate value from `from` to `to` numbers.
|
||
-- Returns function that terminates animation.
|
||
-- `to` can be a function that returns target value, useful for movable targets.
|
||
-- `speed` is an optional float between 1-instant and 0-infinite duration
|
||
-- `callback` is called either on animation end, or when animation is canceled
|
||
function tween(from, to, setter, speed, callback)
|
||
if type(speed) ~= 'number' then
|
||
callback = speed
|
||
speed = 0.3
|
||
end
|
||
|
||
local timeout
|
||
local getTo = type(to) == 'function' and to or function() return to end
|
||
local cutoff = math.abs(getTo() - from) * 0.01
|
||
|
||
local function tick()
|
||
from = from + ((getTo() - from) * speed)
|
||
local is_end = math.abs(getTo() - from) <= cutoff
|
||
setter(is_end and getTo() or from)
|
||
request_render()
|
||
if is_end then
|
||
call_me_maybe(callback)
|
||
else
|
||
timeout:resume()
|
||
end
|
||
end
|
||
|
||
timeout = mp.add_timeout(0.016, tick)
|
||
tick()
|
||
|
||
return function()
|
||
timeout:kill()
|
||
call_me_maybe(callback)
|
||
end
|
||
end
|
||
|
||
-- Kills ongoing animation if one is already running on this element.
|
||
-- Killed animation will not get its `on_end` called.
|
||
function tween_element(element, from, to, setter, speed, callback)
|
||
if type(speed) ~= 'number' then
|
||
callback = speed
|
||
speed = 0.3
|
||
end
|
||
|
||
tween_element_stop(element)
|
||
|
||
element.stop_current_animation = tween(
|
||
from, to,
|
||
function(value) setter(element, value) end,
|
||
speed,
|
||
function()
|
||
element.stop_current_animation = nil
|
||
call_me_maybe(callback, element)
|
||
end
|
||
)
|
||
end
|
||
|
||
-- Stopped animation will not get its on_end called.
|
||
function tween_element_is_tweening(element)
|
||
return element and element.stop_current_animation
|
||
end
|
||
|
||
-- Stopped animation will not get its on_end called.
|
||
function tween_element_stop(element)
|
||
call_me_maybe(element and element.stop_current_animation)
|
||
end
|
||
|
||
-- Helper to automatically use an element property setter
|
||
function tween_element_property(element, prop, from, to, speed, callback)
|
||
tween_element(element, from, to, function(_, value) element[prop] = value end, speed, callback)
|
||
end
|
||
|
||
function get_point_to_rectangle_proximity(point, rect)
|
||
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx + 1)
|
||
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by + 1)
|
||
return math.sqrt(dx * dx + dy * dy);
|
||
end
|
||
|
||
function text_width_estimate(text, font_size)
|
||
if not text or text == '' then return 0 end
|
||
local text_width = 0
|
||
for _, _, width in utf8_iter(text) do
|
||
text_width = text_width + width
|
||
end
|
||
return text_width * font_size * options.font_height_to_letter_width_ratio
|
||
end
|
||
|
||
function utf8_iter(string)
|
||
local byte_start = 1
|
||
local byte_count = 1
|
||
|
||
return function()
|
||
if #string < byte_start then return nil end
|
||
|
||
local char_byte = string.byte(string, byte_start)
|
||
|
||
byte_count = 1;
|
||
if char_byte < 192 then byte_count = 1
|
||
elseif char_byte < 224 then byte_count = 2
|
||
elseif char_byte < 240 then byte_count = 3
|
||
elseif char_byte < 248 then byte_count = 4
|
||
elseif char_byte < 252 then byte_count = 5
|
||
elseif char_byte < 254 then byte_count = 6
|
||
end
|
||
|
||
local start = byte_start
|
||
byte_start = byte_start + byte_count
|
||
|
||
return start, byte_count, (byte_count > 2 and 2 or 1)
|
||
end
|
||
end
|
||
|
||
function wrap_text(text, line_width_requested)
|
||
local line_width = 0
|
||
local wrap_at_chars = {' ', ' ', '-', '–'}
|
||
local remove_when_wrap = {' ', ' '}
|
||
local lines = {}
|
||
local line_start = 1
|
||
local before_end = nil
|
||
local before_width = 0
|
||
local before_line_start = 0
|
||
local before_removed_width = 0
|
||
local max_width = 0
|
||
for char_start, count, char_width in utf8_iter(text) do
|
||
local char_end = char_start + count - 1
|
||
local char = text.sub(text, char_start, char_end)
|
||
local can_wrap = false
|
||
for _, c in ipairs(wrap_at_chars) do
|
||
if char == c then
|
||
can_wrap = true
|
||
break
|
||
end
|
||
end
|
||
line_width = line_width + char_width
|
||
if can_wrap or (char_end == #text) then
|
||
local remove = false
|
||
for _, c in ipairs(remove_when_wrap) do
|
||
if char == c then
|
||
remove = true
|
||
break
|
||
end
|
||
end
|
||
local line_width_after_remove = line_width - (remove and char_width or 0)
|
||
if line_width_after_remove < line_width_requested then
|
||
before_end = remove and char_start - 1 or char_end
|
||
before_width = line_width_after_remove
|
||
before_line_start = char_end + 1
|
||
before_removed_width = remove and char_width or 0
|
||
else
|
||
if (line_width_requested - before_width) <
|
||
(line_width_after_remove - line_width_requested) then
|
||
lines[#lines + 1] = text.sub(text, line_start, before_end)
|
||
line_start = before_line_start
|
||
line_width = line_width - before_width - before_removed_width
|
||
if before_width > max_width then max_width = before_width end
|
||
else
|
||
lines[#lines + 1] = text.sub(text, line_start, remove and char_start - 1 or char_end)
|
||
line_start = char_end + 1
|
||
line_width = remove and line_width - char_width or line_width
|
||
if line_width > max_width then max_width = line_width end
|
||
line_width = 0
|
||
end
|
||
before_end = line_start
|
||
before_width = 0
|
||
end
|
||
end
|
||
end
|
||
if #text >= line_start then
|
||
lines[#lines + 1] = string.sub(text, line_start)
|
||
if line_width > max_width then max_width = line_width end
|
||
end
|
||
return table.concat(lines, '\n'), max_width
|
||
end
|
||
|
||
-- Escape a string for verbatim display on the OSD
|
||
function ass_escape(str)
|
||
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
||
-- it isn't followed by a recognised character, so add a zero-width
|
||
-- non-breaking space
|
||
str = str:gsub('\\', '\\\239\187\191')
|
||
str = str:gsub('{', '\\{')
|
||
str = str:gsub('}', '\\}')
|
||
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
||
-- consecutive newlines
|
||
str = str:gsub('\n', '\239\187\191\\N')
|
||
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
|
||
str = str:gsub('\\N ', '\\N\\h')
|
||
str = str:gsub('^ ', '\\h')
|
||
return str
|
||
end
|
||
|
||
---@param seconds number
|
||
---@return string
|
||
function format_time(seconds)
|
||
local human = mp.format_time(seconds)
|
||
if options.time_precision > 0 then
|
||
local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
|
||
human = human .. '.' .. string.sub(formatted, 3)
|
||
end
|
||
return human
|
||
end
|
||
|
||
function opacity_to_alpha(opacity)
|
||
return 255 - math.ceil(255 * opacity)
|
||
end
|
||
|
||
function ass_opacity(opacity, fraction)
|
||
fraction = fraction ~= nil and fraction or 1
|
||
if type(opacity) == 'number' then
|
||
return string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction))
|
||
else
|
||
return string.format(
|
||
'{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}',
|
||
opacity_to_alpha((opacity[1] or 0) * fraction),
|
||
opacity_to_alpha((opacity[2] or 0) * fraction),
|
||
opacity_to_alpha((opacity[3] or 0) * fraction),
|
||
opacity_to_alpha((opacity[4] or 0) * fraction)
|
||
)
|
||
end
|
||
end
|
||
|
||
-- Ensures path is absolute and normalizes slashes to the current platform
|
||
function normalize_path(path)
|
||
if not path or is_protocol(path) then return path end
|
||
|
||
-- Ensure path is absolute
|
||
if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then
|
||
path = utils.join_path(state.cwd, path)
|
||
end
|
||
|
||
-- Remove trailing slashes
|
||
if #path > 1 then
|
||
path = path:gsub('[\\/]+$', '')
|
||
path = #path == 0 and '/' or path
|
||
end
|
||
|
||
-- Use proper slashes
|
||
if state.os == 'windows' then
|
||
-- Drive letters on windows need trailing backslash
|
||
if path:sub(#path) == ':' then
|
||
path = path .. '\\'
|
||
end
|
||
|
||
return path:gsub('/', '\\')
|
||
else
|
||
return path:gsub('\\', '/')
|
||
end
|
||
end
|
||
|
||
-- Check if path is a protocol, such as `http://...`
|
||
function is_protocol(path)
|
||
return path:match('^%a[%a%d-_]+://')
|
||
end
|
||
|
||
function get_extension(path)
|
||
local parts = split(path, '%.')
|
||
return parts and #parts > 1 and parts[#parts] or nil
|
||
end
|
||
|
||
-- Serializes path into its semantic parts
|
||
function serialize_path(path)
|
||
if not path or is_protocol(path) then return end
|
||
|
||
local normal_path = normalize_path(path)
|
||
-- normalize_path() already strips slashes, but leaves trailing backslash
|
||
-- for windows drive letters, but we don't need it here.
|
||
local working_path = normal_path:sub(#normal_path) == '\\' and normal_path:sub(1, #normal_path - 1) or normal_path
|
||
local parts = split(working_path, '[\\/]+')
|
||
local basename = parts and parts[#parts] or working_path
|
||
local dirname = #parts > 1
|
||
and table.concat(itable_slice(parts, 1, #parts - 1), state.os == 'windows' and '\\' or '/')
|
||
or nil
|
||
local dot_split = split(basename, '%.')
|
||
|
||
return {
|
||
path = normal_path,
|
||
is_root = dirname == nil,
|
||
dirname = dirname,
|
||
basename = basename,
|
||
filename = #dot_split > 1 and table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or basename,
|
||
extension = #dot_split > 1 and dot_split[#dot_split] or nil,
|
||
}
|
||
end
|
||
|
||
function get_files_in_directory(directory, allowed_types)
|
||
local files, error = utils.readdir(directory, 'files')
|
||
|
||
if not files then
|
||
msg.error('Retrieving files failed: ' .. (error or ''))
|
||
return
|
||
end
|
||
|
||
-- Filter only requested file types
|
||
if allowed_types then
|
||
files = itable_filter(files, function(_, file)
|
||
local extension = get_extension(file)
|
||
return extension and itable_find(allowed_types, extension:lower())
|
||
end)
|
||
end
|
||
|
||
table.sort(files, word_order_comparator)
|
||
|
||
return files
|
||
end
|
||
|
||
function get_adjacent_file(file_path, direction, allowed_types)
|
||
local current_file = serialize_path(file_path)
|
||
if not current_file then return end
|
||
local files = get_files_in_directory(current_file.dirname, allowed_types)
|
||
if not files then return end
|
||
|
||
for index, file in ipairs(files) do
|
||
if current_file.basename == file then
|
||
if direction == 'forward' then
|
||
if files[index + 1] then return utils.join_path(current_file.dirname, files[index + 1]) end
|
||
if options.directory_navigation_loops and files[1] then
|
||
return utils.join_path(current_file.dirname, files[1])
|
||
end
|
||
else
|
||
if files[index - 1] then return utils.join_path(current_file.dirname, files[index - 1]) end
|
||
if options.directory_navigation_loops and files[#files] then
|
||
return utils.join_path(current_file.dirname, files[#files])
|
||
end
|
||
end
|
||
|
||
-- This is the only file in directory
|
||
return nil
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Can't use `os.remove()` as it fails on paths with unicode characters.
|
||
-- Returns `result, error`, result is table of `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
|
||
function delete_file(file_path)
|
||
local args = state.os == 'windows' and {'cmd', '/C', 'del', file_path} or {'rm', file_path}
|
||
return mp.command_native({
|
||
name = 'subprocess',
|
||
args = args,
|
||
playback_only = false,
|
||
capture_stdout = true,
|
||
capture_stderr = true
|
||
})
|
||
end
|
||
|
||
-- Ensures chapters are in chronological order
|
||
function get_normalized_chapters()
|
||
local chapters = mp.get_property_native('chapter-list')
|
||
|
||
if not chapters then return end
|
||
|
||
-- Copy table
|
||
chapters = itable_slice(chapters)
|
||
|
||
-- Ensure chronological order of chapters
|
||
table.sort(chapters, function(a, b) return a.time < b.time end)
|
||
|
||
return chapters
|
||
end
|
||
|
||
function is_element_persistent(name)
|
||
local option_name = name .. '_persistency';
|
||
return (options[option_name].audio and state.is_audio) or (options[option_name].paused and state.pause)
|
||
end
|
||
|
||
-- Element
|
||
--[[
|
||
Signature:
|
||
{
|
||
-- element rectangle coordinates
|
||
ax = 0, ay = 0, bx = 0, by = 0,
|
||
-- cursor<->element relative proximity as a 0-1 floating number
|
||
-- where 0 = completely away, and 1 = touching/hovering
|
||
-- so it's easy to work with and throw into equations
|
||
proximity = 0,
|
||
-- raw cursor<->element proximity in pixels
|
||
proximity_raw = infinity,
|
||
-- called when element is created
|
||
?init = function(this),
|
||
-- called manually when disposing of element
|
||
?destroy = function(this),
|
||
-- triggered when event happens and cursor is above element
|
||
?on_{event_name} = function(this),
|
||
-- triggered when any event happens anywhere on a page
|
||
?on_global_{event_name} = function(this),
|
||
-- object
|
||
?render = function(this_element),
|
||
}
|
||
]]
|
||
local Element = {
|
||
ax = 0, ay = 0, bx = 0, by = 0,
|
||
proximity = 0, proximity_raw = infinity,
|
||
}
|
||
Element.__index = Element
|
||
|
||
function Element.new(props)
|
||
local element = setmetatable(props, Element)
|
||
element._eventListeners = {}
|
||
|
||
-- Flash timer
|
||
element._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
|
||
local getTo = function() return element.proximity end
|
||
element:tween_property('forced_proximity', 1, getTo, function()
|
||
element.forced_proximity = nil
|
||
end)
|
||
end)
|
||
element._flash_out_timer:kill()
|
||
|
||
element:init()
|
||
|
||
return element
|
||
end
|
||
|
||
function Element:init() end
|
||
|
||
function Element:destroy() end
|
||
|
||
-- Call method if it exists
|
||
function Element:maybe(name, ...)
|
||
if self[name] then return self[name](self, ...) end
|
||
end
|
||
|
||
-- Tween helpers
|
||
function Element:tween(...) tween_element(self, ...) end
|
||
|
||
function Element:tween_property(...) tween_element_property(self, ...) end
|
||
|
||
function Element:tween_stop() tween_element_stop(self) end
|
||
|
||
function Element:is_tweening() tween_element_is_tweening(self) end
|
||
|
||
-- Event listeners
|
||
function Element:on(name, handler)
|
||
if self._eventListeners[name] == nil then self._eventListeners[name] = {} end
|
||
local preexistingIndex = itable_find(self._eventListeners[name], handler)
|
||
if preexistingIndex then
|
||
return
|
||
else
|
||
self._eventListeners[name][#self._eventListeners[name] + 1] = handler
|
||
end
|
||
end
|
||
|
||
function Element:off(name, handler)
|
||
if self._eventListeners[name] == nil then return end
|
||
local index = itable_find(self._eventListeners, handler)
|
||
if index then table.remove(self._eventListeners, index) end
|
||
end
|
||
|
||
function Element:trigger(name, ...)
|
||
self:maybe('on_' .. name, ...)
|
||
if self._eventListeners[name] == nil then return end
|
||
for _, handler in ipairs(self._eventListeners[name]) do handler(...) end
|
||
request_render()
|
||
end
|
||
|
||
-- Briefly flashes the element for `options.flash_duration` milliseconds.
|
||
-- Useful to visualize changes of volume and timeline when changed via hotkeys.
|
||
-- Implemented by briefly adding animated `forced_proximity` property to the element.
|
||
function Element:flash()
|
||
if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
|
||
self:tween_stop()
|
||
self.forced_proximity = 1
|
||
self._flash_out_timer:kill()
|
||
self._flash_out_timer:resume()
|
||
end
|
||
end
|
||
|
||
-- ELEMENTS
|
||
|
||
local Elements = {itable = {}}
|
||
Elements.__index = Elements
|
||
local elements = setmetatable({}, Elements)
|
||
|
||
function Elements:add(name, element)
|
||
local insert_index = #Elements.itable + 1
|
||
|
||
-- Replace if element already exists
|
||
if self:has(name) then
|
||
insert_index = itable_find(Elements.itable, function(_, element)
|
||
return element.name == name
|
||
end)
|
||
end
|
||
|
||
element.name = name
|
||
Elements.itable[insert_index] = element
|
||
self[name] = element
|
||
|
||
request_render()
|
||
end
|
||
|
||
function Elements:remove(name, props)
|
||
Elements.itable = itable_remove(Elements.itable, self[name])
|
||
self[name] = nil
|
||
request_render()
|
||
end
|
||
|
||
function Elements:trigger(name, ...)
|
||
for _, element in self:ipairs() do element:trigger(name, ...) end
|
||
end
|
||
|
||
function Elements:has(name) return self[name] ~= nil end
|
||
|
||
function Elements:ipairs() return ipairs(self.itable) end
|
||
|
||
-- MENU
|
||
--[[
|
||
Usage:
|
||
```
|
||
local items = {
|
||
{title = 'Foo title', hint = 'Ctrl+F', value = 'foo'},
|
||
{title = 'Bar title', hint = 'Ctrl+B', value = 'bar'},
|
||
{
|
||
title = 'Submenu',
|
||
items = {
|
||
{title = 'Sub item 1', value = 'sub1'},
|
||
{title = 'Sub item 2', value = 'sub2'}
|
||
}
|
||
}
|
||
}
|
||
|
||
function open_item(value)
|
||
value -- value from `item.value`
|
||
end
|
||
|
||
menu:open(items, open_item)
|
||
```
|
||
]]
|
||
local Menu = {}
|
||
Menu.__index = Menu
|
||
local menu = setmetatable({key_bindings = {}, is_closing = false}, Menu)
|
||
|
||
function Menu:is_open(menu_type)
|
||
return elements.menu ~= nil and (not menu_type or elements.menu.type == menu_type)
|
||
end
|
||
|
||
---@alias MenuItem {title?: string, hint?: string, value: any}
|
||
---@alias MenuOptions {title?: string, active_index?: number, selected_index?: number, on_open?: fun(), on_close?: fun(), parent_menu?: any}
|
||
|
||
---@param items MenuItem[]
|
||
---@param open_item fun(value: any)
|
||
---@param opts? MenuOptions
|
||
function Menu:open(items, open_item, opts)
|
||
opts = opts or {}
|
||
|
||
if menu:is_open() then
|
||
if not opts.parent_menu then
|
||
menu:close(true, function()
|
||
menu:open(items, open_item, opts)
|
||
end)
|
||
return
|
||
end
|
||
else
|
||
menu:enable_key_bindings()
|
||
elements.curtain:fadein()
|
||
end
|
||
|
||
elements:add('menu', Element.new({
|
||
type = nil, -- menu type such as `menu`, `chapters`, ...
|
||
title = nil,
|
||
width = nil,
|
||
height = nil,
|
||
offset_x = 0, -- used to animated from/to left when submenu
|
||
item_height = nil,
|
||
item_spacing = 1,
|
||
item_content_spacing = nil,
|
||
font_size = nil,
|
||
font_size_hint = nil,
|
||
scroll_step = nil, -- item height + item spacing
|
||
scroll_height = nil, -- items + spacings - container height
|
||
scroll_y = 0,
|
||
opacity = 0,
|
||
relative_parent_opacity = 0.4,
|
||
items = items,
|
||
active_index = nil,
|
||
selected_index = nil,
|
||
open_item = open_item,
|
||
parent_menu = nil,
|
||
init = function(this)
|
||
-- Already initialized
|
||
if this.width ~= nil then return end
|
||
|
||
-- Apply options
|
||
for key, value in pairs(opts) do this[key] = value end
|
||
|
||
if not this.selected_index then
|
||
this.selected_index = this.active_index
|
||
end
|
||
|
||
-- Set initial dimensions
|
||
this:update_dimensions()
|
||
this:on_display_change()
|
||
|
||
-- Scroll to selected item
|
||
this:scroll_to_item(this.selected_index)
|
||
|
||
-- Transition in animation
|
||
menu.transition = {to = 'child', target = this}
|
||
local start_offset = this.parent_menu and (this.parent_menu.width + this.width) / 2 or 0
|
||
|
||
tween_element(menu.transition.target, 0, 1, function(_, pos)
|
||
this:set_offset_x(round(start_offset * (1 - pos)))
|
||
this.opacity = pos
|
||
this:set_parent_opacity(1 - ((1 - options.menu_parent_opacity) * pos))
|
||
end, function()
|
||
menu.transition = nil
|
||
update_proximities()
|
||
end)
|
||
end,
|
||
destroy = function(this)
|
||
request_render()
|
||
end,
|
||
update_dimensions = function(this)
|
||
this.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height
|
||
this.font_size = round(this.item_height * 0.48 * options.menu_font_scale)
|
||
this.font_size_hint = this.font_size - 1
|
||
this.item_content_spacing = round((this.item_height - this.font_size) * 0.6)
|
||
this.scroll_step = this.item_height + this.item_spacing
|
||
|
||
-- Estimate width of a widest item
|
||
local estimated_max_width = 0
|
||
for _, item in ipairs(this.items) do
|
||
local spacings_in_item = item.hint and 3 or 2
|
||
local has_submenu = item.items ~= nil
|
||
-- M as a stand in for icon
|
||
local hint_icon = item.hint or (has_submenu and 'M' or nil)
|
||
local hint_icon_size = item.hint and this.font_size_hint or this.font_size
|
||
local estimated_width = text_width_estimate(item.title, this.font_size)
|
||
+ text_width_estimate(hint_icon, hint_icon_size)
|
||
+ (this.item_content_spacing * spacings_in_item)
|
||
if estimated_width > estimated_max_width then
|
||
estimated_max_width = estimated_width
|
||
end
|
||
end
|
||
|
||
-- Also check menu title
|
||
local menu_title = this.title and this.title or ''
|
||
local estimated_menu_title_width = text_width_estimate(menu_title, this.font_size)
|
||
if estimated_menu_title_width > estimated_max_width then
|
||
estimated_max_width = estimated_menu_title_width
|
||
end
|
||
|
||
-- Coordinates and sizes are of the scrollable area to make
|
||
-- consuming values in rendering easier. Title drawn above this, so
|
||
-- we need to account for that in max_height and ay position.
|
||
local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width
|
||
this.width = round(math.min(math.max(estimated_max_width, min_width), display.width * 0.9))
|
||
local title_height = this.title and this.scroll_step or 0
|
||
local max_height = round(display.height * 0.9) - title_height
|
||
this.height = math.min(round(this.scroll_step * #this.items) - this.item_spacing, max_height)
|
||
this.scroll_height = math.max((this.scroll_step * #this.items) - this.height - this.item_spacing, 0)
|
||
|
||
-- Update offsets for new sizes
|
||
this:set_offset_x(this.offset_x)
|
||
|
||
if this.parent_menu then
|
||
this.parent_menu:update_dimensions()
|
||
end
|
||
end,
|
||
on_display_change = function(this)
|
||
local title_height = this.title and this.scroll_step or 0
|
||
this.ax = round((display.width - this.width) / 2) + this.offset_x
|
||
this.ay = round((display.height - this.height) / 2 + (title_height / 2))
|
||
this.bx = round(this.ax + this.width)
|
||
this.by = round(this.ay + this.height)
|
||
|
||
if this.parent_menu then
|
||
this.parent_menu:on_display_change()
|
||
end
|
||
end,
|
||
update = function(this, props)
|
||
if props then
|
||
for key, value in pairs(props) do this[key] = value end
|
||
end
|
||
|
||
-- Trigger changes and re-render
|
||
this:update_dimensions()
|
||
this:on_display_change()
|
||
|
||
-- Reset indexes and scroll
|
||
this:select_index(this.selected_index)
|
||
this:activate_index(this.active_index)
|
||
this:scroll_to(this.scroll_y)
|
||
request_render()
|
||
end,
|
||
set_offset_x = function(this, offset)
|
||
local delta = offset - this.offset_x
|
||
this.offset_x = offset
|
||
this.ax = this.ax + delta
|
||
this.bx = this.bx + delta
|
||
if this.parent_menu then
|
||
this.parent_menu:set_offset_x(offset - ((this.width + this.parent_menu.width) / 2) - this.item_spacing)
|
||
else
|
||
update_proximities()
|
||
end
|
||
end,
|
||
fadeout = function(this, callback)
|
||
this:tween(1, 0, function(this, pos)
|
||
this.opacity = pos
|
||
this:set_parent_opacity(pos * options.menu_parent_opacity)
|
||
end, callback)
|
||
end,
|
||
set_parent_opacity = function(this, opacity)
|
||
if this.parent_menu then
|
||
this.parent_menu.opacity = opacity
|
||
this.parent_menu:set_parent_opacity(opacity * options.menu_parent_opacity)
|
||
end
|
||
end,
|
||
get_item_index_below_cursor = function(this)
|
||
if #items < 1 then return nil end
|
||
return math.max(1, math.min(math.ceil((cursor.y - this.ay + this.scroll_y) / this.scroll_step), #this.items))
|
||
end,
|
||
scroll_to = function(this, pos)
|
||
this.scroll_y = math.max(math.min(pos, this.scroll_height), 0)
|
||
request_render()
|
||
end,
|
||
scroll_to_item = function(this, index)
|
||
if (index and index >= 1 and index <= #this.items) then
|
||
this:scroll_to(round((this.scroll_step * (index - 1)) - ((this.height - this.scroll_step) / 2)))
|
||
end
|
||
end,
|
||
select_index = function(this, index)
|
||
this.selected_index = (index and index >= 1 and index <= #this.items) and index or nil
|
||
request_render()
|
||
end,
|
||
select_value = function(this, value)
|
||
this:select_index(itable_find(this.items, function(_, item) return item.value == value end))
|
||
end,
|
||
activate_index = function(this, index)
|
||
this.active_index = (index and index >= 1 and index <= #this.items) and index or nil
|
||
if not this.selected_index then
|
||
this.selected_index = this.active_index
|
||
this:scroll_to_item(this.selected_index)
|
||
end
|
||
request_render()
|
||
end,
|
||
activate_value = function(this, value)
|
||
this:activate_index(itable_find(this.items, function(_, item) return item.value == value end))
|
||
end,
|
||
delete_index = function(this, index)
|
||
if (index and index >= 1 and index <= #this.items) then
|
||
local previous_active_value = this.active_index and this.items[this.active_index].value or nil
|
||
table.remove(this.items, index)
|
||
this:update_dimensions()
|
||
this:on_display_change()
|
||
if previous_active_value then this:activate_value(previous_active_value) end
|
||
this:scroll_to_item(this.selected_index)
|
||
end
|
||
end,
|
||
delete_value = function(this, value)
|
||
this:delete_index(itable_find(this.items, function(_, item) return item.value == value end))
|
||
end,
|
||
prev = function(this)
|
||
this.selected_index = math.max(this.selected_index and this.selected_index - 1 or #this.items, 1)
|
||
this:scroll_to_item(this.selected_index)
|
||
end,
|
||
next = function(this)
|
||
this.selected_index = math.min(this.selected_index and this.selected_index + 1 or 1, #this.items)
|
||
this:scroll_to_item(this.selected_index)
|
||
end,
|
||
back = function(this)
|
||
if menu.transition then
|
||
local transition_target = menu.transition.target
|
||
local transition_target_type = menu.transition.target
|
||
tween_element_stop(transition_target)
|
||
if transition_target_type == 'parent' then
|
||
elements:add('menu', transition_target)
|
||
end
|
||
menu.transition = nil
|
||
if transition_target then transition_target:back() end
|
||
return
|
||
else
|
||
menu.transition = {to = 'parent', target = this.parent_menu}
|
||
end
|
||
|
||
if menu.transition.target == nil then
|
||
menu:close()
|
||
return
|
||
end
|
||
|
||
local target = menu.transition.target
|
||
local to_offset = -target.offset_x + this.offset_x
|
||
|
||
tween_element(target, 0, 1, function(_, pos)
|
||
this:set_offset_x(round(to_offset * pos))
|
||
this.opacity = 1 - pos
|
||
this:set_parent_opacity(options.menu_parent_opacity + ((1 - options.menu_parent_opacity) * pos))
|
||
end, function()
|
||
menu.transition = nil
|
||
elements:add('menu', target)
|
||
update_proximities()
|
||
end)
|
||
end,
|
||
open_selected_item = function(this, soft)
|
||
-- If there is a transition active and this method got called, it
|
||
-- means we are animating from this menu to parent menu, and all
|
||
-- calls to this method should be relayed to the parent menu.
|
||
if menu.transition and menu.transition.to == 'parent' then
|
||
local target = menu.transition.target
|
||
tween_element_stop(target)
|
||
menu.transition = nil
|
||
if target then target:open_selected_item(soft) end
|
||
return
|
||
end
|
||
|
||
if this.selected_index then
|
||
local item = this.items[this.selected_index]
|
||
-- Is submenu
|
||
if item.items then
|
||
local opts = table_copy(opts)
|
||
opts.parent_menu = this
|
||
menu:open(item.items, this.open_item, opts)
|
||
else
|
||
if soft ~= true then menu:close(true) end
|
||
this.open_item(item.value)
|
||
end
|
||
end
|
||
end,
|
||
open_selected_item_soft = function(this) this:open_selected_item(true) end,
|
||
close = function(this) menu:close() end,
|
||
on_prop_fullormaxed = function(this) this:update_dimensions() end,
|
||
on_global_mbtn_left_down = function(this)
|
||
if this.proximity_raw == 0 then
|
||
this.selected_index = this:get_item_index_below_cursor()
|
||
this:open_selected_item()
|
||
else
|
||
-- check if this is clicking on any parent menus
|
||
local parent_menu = this.parent_menu
|
||
repeat
|
||
if parent_menu then
|
||
if get_point_to_rectangle_proximity(cursor, parent_menu) == 0 then
|
||
this:back()
|
||
return
|
||
end
|
||
parent_menu = parent_menu.parent_menu
|
||
end
|
||
until parent_menu == nil
|
||
|
||
menu:close()
|
||
end
|
||
end,
|
||
on_global_mouse_move = function(this)
|
||
if this.proximity_raw == 0 then
|
||
this.selected_index = this:get_item_index_below_cursor()
|
||
else
|
||
if this.selected_index then this.selected_index = nil end
|
||
end
|
||
request_render()
|
||
end,
|
||
on_wheel_up = function(this)
|
||
this.selected_index = nil
|
||
this:scroll_to(this.scroll_y - this.scroll_step)
|
||
-- Selects item below cursor
|
||
this:on_global_mouse_move()
|
||
request_render()
|
||
end,
|
||
on_wheel_down = function(this)
|
||
this.selected_index = nil
|
||
this:scroll_to(this.scroll_y + this.scroll_step)
|
||
-- Selects item below cursor
|
||
this:on_global_mouse_move()
|
||
request_render()
|
||
end,
|
||
on_pgup = function(this)
|
||
local items_per_page = round((this.height / this.scroll_step) * 0.4)
|
||
local paged_index = (this.selected_index and this.selected_index or #this.items) - items_per_page
|
||
this.selected_index = math.min(math.max(1, paged_index), #this.items)
|
||
if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end
|
||
end,
|
||
on_pgdwn = function(this)
|
||
local items_per_page = round((this.height / this.scroll_step) * 0.4)
|
||
local paged_index = (this.selected_index and this.selected_index or 1) + items_per_page
|
||
this.selected_index = math.min(math.max(1, paged_index), #this.items)
|
||
if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end
|
||
end,
|
||
on_home = function(this)
|
||
this.selected_index = math.min(1, #this.items)
|
||
if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end
|
||
end,
|
||
on_end = function(this)
|
||
this.selected_index = #this.items
|
||
if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end
|
||
end,
|
||
render = render_menu,
|
||
}))
|
||
|
||
elements.menu:maybe('on_open')
|
||
end
|
||
|
||
function Menu:add_key_binding(key, name, fn, flags)
|
||
menu.key_bindings[#menu.key_bindings + 1] = name
|
||
mp.add_forced_key_binding(key, name, fn, flags)
|
||
end
|
||
|
||
function Menu:enable_key_bindings()
|
||
menu.key_bindings = {}
|
||
-- The `mp.set_key_bindings()` method would be easier here, but that
|
||
-- doesn't support 'repeatable' flag, so we are stuck with this monster.
|
||
menu:add_key_binding('up', 'menu-prev1', self:create_action('prev'), 'repeatable')
|
||
menu:add_key_binding('down', 'menu-next1', self:create_action('next'), 'repeatable')
|
||
menu:add_key_binding('left', 'menu-back1', self:create_action('back'))
|
||
menu:add_key_binding('right', 'menu-select1', self:create_action('open_selected_item'))
|
||
menu:add_key_binding('shift+right', 'menu-select-soft1', self:create_action('open_selected_item_soft'))
|
||
menu:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_action('open_selected_item_soft'))
|
||
|
||
if options.menu_wasd_navigation then
|
||
menu:add_key_binding('w', 'menu-prev2', self:create_action('prev'), 'repeatable')
|
||
menu:add_key_binding('a', 'menu-back2', self:create_action('back'))
|
||
menu:add_key_binding('s', 'menu-next2', self:create_action('next'), 'repeatable')
|
||
menu:add_key_binding('d', 'menu-select2', self:create_action('open_selected_item'))
|
||
menu:add_key_binding('shift+d', 'menu-select-soft2', self:create_action('open_selected_item_soft'))
|
||
end
|
||
|
||
if options.menu_hjkl_navigation then
|
||
menu:add_key_binding('h', 'menu-back3', self:create_action('back'))
|
||
menu:add_key_binding('j', 'menu-next3', self:create_action('next'), 'repeatable')
|
||
menu:add_key_binding('k', 'menu-prev3', self:create_action('prev'), 'repeatable')
|
||
menu:add_key_binding('l', 'menu-select3', self:create_action('open_selected_item'))
|
||
menu:add_key_binding('shift+l', 'menu-select-soft3', self:create_action('open_selected_item_soft'))
|
||
end
|
||
|
||
menu:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_action('back'))
|
||
menu:add_key_binding('bs', 'menu-back-alt4', self:create_action('back'))
|
||
menu:add_key_binding('enter', 'menu-select-alt3', self:create_action('open_selected_item'))
|
||
menu:add_key_binding('kp_enter', 'menu-select-alt4', self:create_action('open_selected_item'))
|
||
menu:add_key_binding('esc', 'menu-close', self:create_action('close'))
|
||
menu:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup'))
|
||
menu:add_key_binding('pgdwn', 'menu-page-down', self:create_action('on_pgdwn'))
|
||
menu:add_key_binding('home', 'menu-home', self:create_action('on_home'))
|
||
menu:add_key_binding('end', 'menu-end', self:create_action('on_end'))
|
||
end
|
||
|
||
function Menu:disable_key_bindings()
|
||
for _, name in ipairs(menu.key_bindings) do mp.remove_key_binding(name) end
|
||
menu.key_bindings = {}
|
||
end
|
||
|
||
function Menu:create_action(name)
|
||
return function(...)
|
||
if elements.menu then elements.menu:maybe(name, ...) end
|
||
end
|
||
end
|
||
|
||
function Menu:close(immediate, callback)
|
||
if type(immediate) ~= 'boolean' then callback = immediate end
|
||
|
||
if elements:has('menu') and not menu.is_closing then
|
||
local function close()
|
||
elements.menu:maybe('on_close')
|
||
elements.menu:destroy()
|
||
elements:remove('menu')
|
||
menu.is_closing = false
|
||
update_proximities()
|
||
menu:disable_key_bindings()
|
||
call_me_maybe(callback)
|
||
end
|
||
|
||
menu.is_closing = true
|
||
elements.curtain:fadeout()
|
||
|
||
if immediate then
|
||
close()
|
||
else
|
||
elements.menu:fadeout(close)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- ICONS
|
||
--[[
|
||
ASS \shadN shadows are drawn also below the element, which when there is an
|
||
opacity in play, blends icon colors into ugly greys. The mess below is an
|
||
attempt to fix it by rendering shadows for icons with clipping.
|
||
|
||
Add icons by adding functions to render them to `icons` table.
|
||
|
||
Signature: function(pos_x, pos_y, size) => string
|
||
|
||
Function has to return ass path coordinates to draw the icon centered at pox_x
|
||
and pos_y of passed size.
|
||
]]
|
||
local icons = {}
|
||
function icon(name, icon_x, icon_y, icon_size, shad_x, shad_y, shad_size, backdrop, opacity, clip)
|
||
local ass = assdraw.ass_new()
|
||
local icon_path = icons[name](icon_x, icon_y, icon_size)
|
||
local icon_color = options['color_' .. backdrop .. '_text']
|
||
local shad_color = options['color_' .. backdrop]
|
||
local use_border = (shad_x + shad_y) == 0
|
||
local icon_border = use_border and shad_size or 0
|
||
|
||
-- clip can't clip out shadows, a very annoying limitation I can't work
|
||
-- around without going back to ugly default ass shadows, but atm I actually
|
||
-- don't need clipping of icons with shadows, so I'm choosing to ignore this
|
||
if not clip then
|
||
clip = ''
|
||
end
|
||
|
||
if not use_border then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. shad_color .. '\\iclip(' .. ass.scale .. ', ' .. icon_path .. ')}')
|
||
ass:append(ass_opacity(opacity))
|
||
ass:pos(shad_x + shad_size, shad_y + shad_size)
|
||
ass:draw_start()
|
||
ass:append(icon_path)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord' .. icon_border .. '\\shad0\\1c&H' .. icon_color .. '\\3c&H' .. shad_color .. clip .. '}')
|
||
ass:append(ass_opacity(opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:append(icon_path)
|
||
ass:draw_stop()
|
||
|
||
return ass.text
|
||
end
|
||
|
||
function icons._volume(muted, pos_x, pos_y, size)
|
||
local ass = assdraw.ass_new()
|
||
local scale = size / 200
|
||
local function x(number) return pos_x + (number * scale) end
|
||
local function y(number) return pos_y + (number * scale) end
|
||
ass:move_to(x(-85), y(-35))
|
||
ass:line_to(x(-50), y(-35))
|
||
ass:line_to(x(-5), y(-75))
|
||
ass:line_to(x(-5), y(75))
|
||
ass:line_to(x(-50), y(35))
|
||
ass:line_to(x(-85), y(35))
|
||
if muted then
|
||
ass:move_to(x(76), y(-35))
|
||
ass:line_to(x(50), y(-9))
|
||
ass:line_to(x(24), y(-35))
|
||
ass:line_to(x(15), y(-26))
|
||
ass:line_to(x(41), y(0))
|
||
ass:line_to(x(15), y(26))
|
||
ass:line_to(x(24), y(35))
|
||
ass:line_to(x(50), y(9))
|
||
ass:line_to(x(76), y(35))
|
||
ass:line_to(x(85), y(26))
|
||
ass:line_to(x(59), y(0))
|
||
ass:line_to(x(85), y(-26))
|
||
else
|
||
ass:move_to(x(20), y(-30))
|
||
ass:line_to(x(20), y(30))
|
||
ass:line_to(x(35), y(30))
|
||
ass:line_to(x(35), y(-30))
|
||
|
||
ass:move_to(x(55), y(-60))
|
||
ass:line_to(x(55), y(60))
|
||
ass:line_to(x(70), y(60))
|
||
ass:line_to(x(70), y(-60))
|
||
end
|
||
return ass.text
|
||
end
|
||
|
||
function icons.volume(pos_x, pos_y, size) return icons._volume(false, pos_x, pos_y, size) end
|
||
|
||
function icons.volume_muted(pos_x, pos_y, size) return icons._volume(true, pos_x, pos_y, size) end
|
||
|
||
function icons.menu_button(pos_x, pos_y, size)
|
||
local ass = assdraw.ass_new()
|
||
local scale = size / 100
|
||
local function x(number) return pos_x + (number * scale) end
|
||
local function y(number) return pos_y + (number * scale) end
|
||
local line_height = 14
|
||
local line_spacing = 18
|
||
for i = -1, 1 do
|
||
local offs = i * (line_height + line_spacing)
|
||
ass:move_to(x(-50), y(offs - line_height / 2))
|
||
ass:line_to(x(50), y(offs - line_height / 2))
|
||
ass:line_to(x(50), y(offs + line_height / 2))
|
||
ass:line_to(x(-50), y(offs + line_height / 2))
|
||
end
|
||
return ass.text
|
||
end
|
||
|
||
function icons.arrow_right(pos_x, pos_y, size)
|
||
local ass = assdraw.ass_new()
|
||
local scale = size / 200
|
||
local function x(number) return pos_x + (number * scale) end
|
||
local function y(number) return pos_y + (number * scale) end
|
||
ass:move_to(x(-22), y(-80))
|
||
ass:line_to(x(-45), y(-57))
|
||
ass:line_to(x(12), y(0))
|
||
ass:line_to(x(-45), y(57))
|
||
ass:line_to(x(-22), y(80))
|
||
ass:line_to(x(58), y(0))
|
||
return ass.text
|
||
end
|
||
|
||
-- STATE UPDATES
|
||
|
||
function update_display_dimensions()
|
||
local dpi_scale = mp.get_property_native('display-hidpi-scale', 1.0)
|
||
dpi_scale = dpi_scale * options.ui_scale
|
||
|
||
local width, height, aspect = mp.get_osd_size()
|
||
display.width = width / dpi_scale
|
||
display.height = height / dpi_scale
|
||
display.aspect = aspect
|
||
|
||
-- Tell elements about this
|
||
elements:trigger('display_change')
|
||
|
||
-- Some elements probably changed their rectangles as a reaction to `display_change`
|
||
update_proximities()
|
||
request_render()
|
||
end
|
||
|
||
function update_element_cursor_proximity(element)
|
||
if cursor.hidden then
|
||
element.proximity_raw = infinity
|
||
element.proximity = 0
|
||
else
|
||
local range = options.proximity_out - options.proximity_in
|
||
element.proximity_raw = get_point_to_rectangle_proximity(cursor, element)
|
||
element.proximity = menu:is_open() and 0 or
|
||
1 - (math.min(math.max(element.proximity_raw - options.proximity_in, 0), range) / range)
|
||
end
|
||
end
|
||
|
||
function update_proximities()
|
||
local capture_mbtn_left = false
|
||
local capture_wheel = false
|
||
local menu_only = menu:is_open()
|
||
local mouse_leave_elements = {}
|
||
local mouse_enter_elements = {}
|
||
|
||
-- Calculates proximities and opacities for defined elements
|
||
for _, element in elements:ipairs() do
|
||
local previous_proximity_raw = element.proximity_raw
|
||
|
||
-- If menu is open, all other elements have to be disabled
|
||
if menu_only then
|
||
if element.name == 'menu' then
|
||
capture_mbtn_left = true
|
||
capture_wheel = true
|
||
update_element_cursor_proximity(element)
|
||
else
|
||
element.proximity_raw = infinity
|
||
element.proximity = 0
|
||
end
|
||
else
|
||
update_element_cursor_proximity(element)
|
||
end
|
||
|
||
-- Element has global forced key listeners
|
||
if element.on_global_mbtn_left_down then capture_mbtn_left = true end
|
||
if element.on_global_wheel_up or element.on_global_wheel_down then capture_wheel = true end
|
||
|
||
if element.proximity_raw == 0 then
|
||
-- Element has local forced key listeners
|
||
if element.on_mbtn_left_down then capture_mbtn_left = true end
|
||
if element.on_wheel_up or element.on_wheel_up then capture_wheel = true end
|
||
|
||
-- Mouse entered element area
|
||
if previous_proximity_raw ~= 0 then
|
||
mouse_enter_elements[#mouse_enter_elements + 1] = element
|
||
end
|
||
else
|
||
-- Mouse left element area
|
||
if previous_proximity_raw == 0 then
|
||
mouse_leave_elements[#mouse_leave_elements + 1] = element
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Enable key group captures elements request.
|
||
if capture_mbtn_left then
|
||
forced_key_bindings.mbtn_left:enable()
|
||
else
|
||
forced_key_bindings.mbtn_left:disable()
|
||
end
|
||
if capture_wheel then
|
||
forced_key_bindings.wheel:enable()
|
||
else
|
||
forced_key_bindings.wheel:disable()
|
||
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
|
||
|
||
function update_human_times()
|
||
if state.time then
|
||
state.time_human = format_time(state.time)
|
||
if state.duration then
|
||
state.duration_or_remaining_time_human = format_time(
|
||
options.total_time and state.duration or state.time - state.duration
|
||
)
|
||
else
|
||
state.duration_or_remaining_time_human = nil
|
||
end
|
||
else
|
||
state.time_human = nil
|
||
end
|
||
end
|
||
|
||
-- ELEMENT RENDERERS
|
||
|
||
function render_timeline(this)
|
||
if this.size_max == 0 or state.duration == nil or state.duration == 0 or state.time == nil then return end
|
||
|
||
local size_min = this:get_effective_size_min()
|
||
local size = this:get_effective_size()
|
||
|
||
if size < 1 then return end
|
||
|
||
local ass = assdraw.ass_new()
|
||
|
||
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
|
||
local hide_text_below = math.max(this.font_size * 0.7, size_min * 2)
|
||
local hide_text_ramp = hide_text_below / 2
|
||
local text_opacity = math.max(math.min(size - hide_text_below, hide_text_ramp), 0) / hide_text_ramp
|
||
|
||
local spacing = math.max(math.floor((this.size_max - this.font_size) / 2.5), 4)
|
||
local progress = state.time / state.duration
|
||
local is_line = options.timeline_style == 'line'
|
||
|
||
-- Background bar coordinates
|
||
local bax = this.ax
|
||
local bay = this.by - size - this.top_border
|
||
local bbx = this.bx
|
||
local bby = this.by
|
||
|
||
-- Foreground bar coordinates
|
||
local fax = 0
|
||
local fay = bay + this.top_border
|
||
local fbx = 0
|
||
local fby = bby
|
||
|
||
-- Controls the padding of time on the timeline due to line width.
|
||
-- It's a distance of the center of the line when from the side when at the
|
||
-- start or end of the timeline. Effectively half of the line width.
|
||
local time_padding = 0
|
||
|
||
if is_line then
|
||
local minimized_fraction = 1 - (size - size_min) / (this.size_max - size_min)
|
||
local width_normal = this:get_effective_line_width()
|
||
local normal_minimized_delta = width_normal - width_normal * options.timeline_line_width_minimized_scale
|
||
local line_width = width_normal - (normal_minimized_delta * minimized_fraction)
|
||
local current_time_x = round((bbx - bax - line_width) * progress)
|
||
fax = current_time_x
|
||
fbx = fax + line_width
|
||
if line_width > 2 then time_padding = round(line_width / 2) end
|
||
else
|
||
fax = bax
|
||
fay = bay + this.top_border
|
||
fbx = round(bax + this.width * progress)
|
||
end
|
||
|
||
local time_x = bax + time_padding
|
||
local time_width = this.width - time_padding * 2
|
||
local foreground_size = bby - bay
|
||
local foreground_coordinates = fax .. ',' .. fay .. ',' .. fbx .. ',' .. fby -- for clipping
|
||
|
||
-- Background
|
||
ass:new_event()
|
||
ass:pos(0, 0)
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '\\iclip(' .. foreground_coordinates .. ')}')
|
||
ass:append(ass_opacity(math.max(options.timeline_opacity - 0.1, 0)))
|
||
ass:draw_start()
|
||
ass:rect_cw(bax, bay, bbx, bby)
|
||
ass:draw_stop()
|
||
|
||
-- Progress
|
||
local function render_progress()
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
||
ass:append(ass_opacity(options.timeline_opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(fax, fay, fbx, fby)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
-- Custom ranges
|
||
local function render_ranges()
|
||
if state.chapter_ranges ~= nil then
|
||
for i, chapter_range in ipairs(state.chapter_ranges) do
|
||
for i, range in ipairs(chapter_range.ranges) do
|
||
local rax = time_x + time_width * (range['start'].time / state.duration)
|
||
local rbx = time_x + time_width * (range['end'].time / state.duration)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. chapter_range.color .. '}')
|
||
ass:append(ass_opacity(chapter_range.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
-- for 1px chapter size, use the whole size of the bar including padding
|
||
if size <= 1 then
|
||
ass:rect_cw(rax, bay, rbx, bby)
|
||
else
|
||
ass:rect_cw(rax, fay, rbx, fby)
|
||
end
|
||
ass:draw_stop()
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Chapters
|
||
local function render_chapters()
|
||
if (
|
||
options.timeline_chapters == 'never'
|
||
or (
|
||
(state.chapters == nil or #state.chapters == 0)
|
||
and state.ab_loop_a == nil
|
||
and state.ab_loop_b == nil
|
||
)
|
||
) then return end
|
||
|
||
local dots = false
|
||
-- Defaults are for `lines`
|
||
local chapter_width = options.timeline_chapters_width
|
||
local chapter_height, chapter_y
|
||
if options.timeline_chapters == 'dots' then
|
||
dots = true
|
||
chapter_height = math.min(chapter_width, (foreground_size / 2) + 1)
|
||
chapter_y = fay + chapter_height / 2
|
||
elseif options.timeline_chapters == 'lines' then
|
||
chapter_height = size
|
||
chapter_y = fay + (chapter_height / 2)
|
||
elseif options.timeline_chapters == 'lines-top' then
|
||
chapter_height = math.min(this.size_max / 3, size)
|
||
chapter_y = fay + (chapter_height / 2)
|
||
elseif options.timeline_chapters == 'lines-bottom' then
|
||
chapter_height = math.min(this.size_max / 3, size)
|
||
chapter_y = fay + size - (chapter_height / 2)
|
||
end
|
||
|
||
if chapter_height ~= nil then
|
||
-- for 1px chapter size, use the whole size of the bar including padding
|
||
chapter_height = size <= 1 and foreground_size or chapter_height
|
||
local chapter_half_width = chapter_width / 2
|
||
local chapter_half_height = chapter_height / 2
|
||
local function draw_chapter(time)
|
||
local chapter_x = time_x + time_width * (time / state.duration)
|
||
local color = (fax < chapter_x and chapter_x < fbx) and options.color_background or options.color_foreground
|
||
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. color .. '}')
|
||
ass:append(ass_opacity(options.timeline_chapters_opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
|
||
if dots then
|
||
local bezier_stretch = chapter_height * 0.67
|
||
ass:move_to(chapter_x - chapter_half_height, chapter_y)
|
||
ass:bezier_curve(
|
||
chapter_x - chapter_half_height, chapter_y - bezier_stretch,
|
||
chapter_x + chapter_half_height, chapter_y - bezier_stretch,
|
||
chapter_x + chapter_half_height, chapter_y
|
||
)
|
||
ass:bezier_curve(
|
||
chapter_x + chapter_half_height, chapter_y + bezier_stretch,
|
||
chapter_x - chapter_half_height, chapter_y + bezier_stretch,
|
||
chapter_x - chapter_half_height, chapter_y
|
||
)
|
||
else
|
||
ass:rect_cw(
|
||
chapter_x - chapter_half_width,
|
||
chapter_y - chapter_half_height,
|
||
chapter_x + chapter_half_width,
|
||
chapter_y + chapter_half_height
|
||
)
|
||
end
|
||
|
||
ass:draw_stop()
|
||
end
|
||
|
||
if state.chapters ~= nil then
|
||
for i, chapter in ipairs(state.chapters) do
|
||
if not chapter._uosc_used_as_range_point then
|
||
draw_chapter(chapter.time)
|
||
end
|
||
end
|
||
end
|
||
|
||
if state.ab_loop_a and state.ab_loop_a > 0 then
|
||
draw_chapter(state.ab_loop_a)
|
||
end
|
||
|
||
if state.ab_loop_b and state.ab_loop_b > 0 then
|
||
draw_chapter(state.ab_loop_b)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Seekable ranges
|
||
local function render_cache()
|
||
if options.timeline_cached_ranges and state.cached_ranges then
|
||
local range_height = math.max(math.min(this.size_max / 8, foreground_size / 3), 1)
|
||
local range_ay = fby - range_height
|
||
|
||
for _, range in ipairs(state.cached_ranges) do
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.timeline_cached_ranges.color .. '}')
|
||
ass:append(ass_opacity(options.timeline_cached_ranges.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
local range_start = math.max(type(range['start']) == 'number' and range['start'] or 0.000001, 0.000001)
|
||
local range_end = math.min(type(range['end']) and range['end'] or state.duration, state.duration)
|
||
ass:rect_cw(
|
||
time_x + time_width * (range_start / state.duration), range_ay,
|
||
time_x + time_width * (range_end / state.duration), range_ay + range_height
|
||
)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
-- Visualize padded time area limits
|
||
if time_padding > 0 then
|
||
local notch_ay = math.max(range_ay - 2, fay)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.timeline_cached_ranges.color .. '}')
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(time_x, notch_ay, time_x + 1, bby)
|
||
ass:draw_stop()
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.timeline_cached_ranges.color .. '}')
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(time_x + time_width - 1, notch_ay, time_x + time_width, bby)
|
||
ass:draw_stop()
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Time values
|
||
local function render_time()
|
||
if text_opacity > 0 then
|
||
-- Elapsed time
|
||
if state.time_human then
|
||
local elapsed_x = bax + spacing
|
||
local elapsed_y = fay + (size / 2)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. options.color_foreground_text .. '\\fn' .. config.font ..
|
||
'\\fs' .. this.font_size .. bold_tag .. '\\clip(' .. foreground_coordinates .. ')')
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity))
|
||
ass:pos(elapsed_x, elapsed_y)
|
||
ass:an(4)
|
||
ass:append(state.time_human)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. options.color_background_text ..
|
||
'\\4c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size ..
|
||
bold_tag .. '\\iclip(' .. foreground_coordinates .. ')')
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity))
|
||
ass:pos(elapsed_x, elapsed_y)
|
||
ass:an(4)
|
||
ass:append(state.time_human)
|
||
end
|
||
|
||
-- End time
|
||
if state.duration_or_remaining_time_human then
|
||
local end_x = bbx - spacing
|
||
local end_y = fay + (size / 2)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. options.color_foreground_text .. '\\fn' .. config.font ..
|
||
'\\fs' .. this.font_size .. bold_tag .. '\\clip(' .. foreground_coordinates .. ')')
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity))
|
||
ass:pos(end_x, end_y)
|
||
ass:an(6)
|
||
ass:append(state.duration_or_remaining_time_human)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. options.color_background_text ..
|
||
'\\4c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size ..
|
||
bold_tag .. '\\iclip(' .. foreground_coordinates .. ')')
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity))
|
||
ass:pos(end_x, end_y)
|
||
ass:an(6)
|
||
ass:append(state.duration_or_remaining_time_human)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Render elements in the optimal order:
|
||
-- When line is minimized, it turns into a bar (timeline_line_width_minimized_scale),
|
||
-- so it should be below ranges and chapters.
|
||
-- But un-minimized it's a thin line that should be above everything.
|
||
if is_line and size > size_min then
|
||
render_ranges()
|
||
render_chapters()
|
||
render_progress()
|
||
render_cache()
|
||
render_time()
|
||
else
|
||
render_progress()
|
||
render_ranges()
|
||
render_chapters()
|
||
render_cache()
|
||
render_time()
|
||
end
|
||
|
||
-- Hovered time and chapter
|
||
if (this.proximity_raw == 0 or this.pressed) and not (elements.speed and elements.speed.dragging) then
|
||
local hovered_seconds = state.duration * (cursor.x / display.width)
|
||
local chapter_title = ''
|
||
local chapter_title_width = 0
|
||
if (options.timeline_chapters ~= 'never' and state.chapters) then
|
||
for i = #state.chapters, 1, -1 do
|
||
local chapter = state.chapters[i]
|
||
if hovered_seconds >= chapter.time then
|
||
chapter_title = chapter.title_wrapped
|
||
chapter_title_width = chapter.title_wrapped_width
|
||
break
|
||
end
|
||
end
|
||
end
|
||
local time_formatted = format_time(hovered_seconds)
|
||
local margin_time = text_width_estimate(time_formatted, this.font_size) / 2
|
||
local margin_title = chapter_title_width * this.font_size * options.font_height_to_letter_width_ratio / 2
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. options.color_background_text ..
|
||
'\\3c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size .. '\\b1')
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1)))
|
||
ass:pos(math.min(math.max(cursor.x, margin_title), display.width - margin_title), fay - this.font_size * 1.5)
|
||
ass:an(2)
|
||
ass:append(chapter_title)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. options.color_background_text ..
|
||
'\\3c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size .. bold_tag)
|
||
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1)))
|
||
ass:pos(math.min(math.max(cursor.x, margin_time), display.width - margin_time), fay)
|
||
ass:an(2)
|
||
ass:append(time_formatted)
|
||
|
||
-- Cursor line
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\xshad-1\\yshad0\\1c&H' .. options.color_foreground ..
|
||
'\\4c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(0.2))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(cursor.x, fay, cursor.x + 1, fby)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
return ass
|
||
end
|
||
|
||
function render_top_bar(this)
|
||
local opacity = this:get_effective_proximity()
|
||
|
||
if not this.enabled or opacity == 0 then return end
|
||
|
||
local ass = assdraw.ass_new()
|
||
|
||
if options.top_bar_controls then
|
||
-- Close button
|
||
local close = elements.window_controls_close
|
||
if close.proximity_raw == 0 then
|
||
-- Background on hover
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H2311e8}')
|
||
ass:append(ass_opacity(this.button_opacity, opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(close.ax, close.ay, close.bx, close.by)
|
||
ass:draw_stop()
|
||
end
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}')
|
||
ass:append(ass_opacity(this.button_opacity, opacity))
|
||
ass:pos(close.ax + (this.button_width / 2), close.ay + (this.size / 2))
|
||
ass:draw_start()
|
||
ass:move_to(-this.icon_size, this.icon_size)
|
||
ass:line_to(this.icon_size, -this.icon_size)
|
||
ass:move_to(-this.icon_size, -this.icon_size)
|
||
ass:line_to(this.icon_size, this.icon_size)
|
||
ass:draw_stop()
|
||
|
||
-- Maximize button
|
||
local maximize = elements.window_controls_maximize
|
||
if maximize.proximity_raw == 0 then
|
||
-- Background on hover
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H222222}')
|
||
ass:append(ass_opacity(this.button_opacity, opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(maximize.ax, maximize.ay, maximize.bx, maximize.by)
|
||
ass:draw_stop()
|
||
end
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&H000000}')
|
||
ass:append(ass_opacity({[3] = this.button_opacity}, opacity))
|
||
ass:pos(maximize.ax + (this.button_width / 2), maximize.ay + (this.size / 2))
|
||
ass:draw_start()
|
||
ass:rect_cw(-this.icon_size + 1, -this.icon_size + 1, this.icon_size + 1, this.icon_size + 1)
|
||
ass:draw_stop()
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&HFFFFFF}')
|
||
ass:append(ass_opacity({[3] = this.button_opacity}, opacity))
|
||
ass:pos(maximize.ax + (this.button_width / 2), maximize.ay + (this.size / 2))
|
||
ass:draw_start()
|
||
ass:rect_cw(-this.icon_size, -this.icon_size, this.icon_size, this.icon_size)
|
||
ass:draw_stop()
|
||
|
||
-- Minimize button
|
||
local minimize = elements.window_controls_minimize
|
||
if minimize.proximity_raw == 0 then
|
||
-- Background on hover
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H222222}')
|
||
ass:append(ass_opacity(this.button_opacity, opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(minimize.ax, minimize.ay, minimize.bx, minimize.by)
|
||
ass:draw_stop()
|
||
end
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}')
|
||
ass:append(ass_opacity(this.button_opacity, opacity))
|
||
ass:append('{\\1a&HFF&}')
|
||
ass:pos(minimize.ax + (this.button_width / 2), minimize.ay + (this.size / 2))
|
||
ass:draw_start()
|
||
ass:move_to(-this.icon_size, 0)
|
||
ass:line_to(this.icon_size, 0)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
-- Window title
|
||
if options.top_bar_title and state.media_title then
|
||
local clip_coordinates = this.ax .. ',' .. this.ay .. ',' .. (this.title_bx - this.spacing) .. ',' .. this.by
|
||
|
||
ass:new_event()
|
||
ass:append('{\\q2\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000\\fn' ..
|
||
config.font .. '\\fs' .. this.font_size .. bold_tag .. '\\clip(' .. clip_coordinates .. ')')
|
||
ass:append(ass_opacity(1, opacity))
|
||
ass:pos(this.ax + this.spacing, this.ay + (this.size / 2))
|
||
ass:an(4)
|
||
if state.playlist_count > 1 then
|
||
ass:append(string.format('%d/%d - ', state.playlist_pos, state.playlist_count))
|
||
end
|
||
ass:append(state.media_title)
|
||
end
|
||
|
||
return ass
|
||
end
|
||
|
||
function render_volume(this)
|
||
local slider = elements.volume_slider
|
||
local opacity = this:get_effective_proximity()
|
||
|
||
if this.width == 0 or opacity == 0 or not state.has_audio then return end
|
||
|
||
local ass = assdraw.ass_new()
|
||
|
||
if slider.height > 0 then
|
||
-- Background bar coordinates
|
||
local bax = slider.ax
|
||
local bay = slider.ay
|
||
local bbx = slider.bx
|
||
local bby = slider.by
|
||
|
||
-- Foreground bar coordinates
|
||
local height_without_border = slider.height - (options.volume_border * 2)
|
||
local fax = slider.ax + options.volume_border
|
||
local fay = slider.ay + (height_without_border * (1 - math.min(state.volume / state.volume_max, 1))) +
|
||
options.volume_border
|
||
local fbx = slider.bx - options.volume_border
|
||
local fby = slider.by - options.volume_border
|
||
|
||
-- Path to draw a foreground bar with a 100% volume indicator, already
|
||
-- clipped by volume level. Can't just clip it with rectangle, as it itself
|
||
-- also needs to be used as a path to clip the background bar and volume
|
||
-- number.
|
||
local fpath = assdraw.ass_new()
|
||
fpath:move_to(fbx, fby)
|
||
fpath:line_to(fax, fby)
|
||
local nudge_bottom_y = slider.nudge_y + slider.nudge_size
|
||
if fay <= nudge_bottom_y and slider.draw_nudge then
|
||
fpath:line_to(fax, math.min(nudge_bottom_y))
|
||
if fay <= slider.nudge_y then
|
||
fpath:line_to((fax + slider.nudge_size), slider.nudge_y)
|
||
local nudge_top_y = slider.nudge_y - slider.nudge_size
|
||
if fay <= nudge_top_y then
|
||
fpath:line_to(fax, nudge_top_y)
|
||
fpath:line_to(fax, fay)
|
||
fpath:line_to(fbx, fay)
|
||
fpath:line_to(fbx, nudge_top_y)
|
||
else
|
||
local triangle_side = fay - nudge_top_y
|
||
fpath:line_to((fax + triangle_side), fay)
|
||
fpath:line_to((fbx - triangle_side), fay)
|
||
end
|
||
fpath:line_to((fbx - slider.nudge_size), slider.nudge_y)
|
||
else
|
||
local triangle_side = nudge_bottom_y - fay
|
||
fpath:line_to((fax + triangle_side), fay)
|
||
fpath:line_to((fbx - triangle_side), fay)
|
||
end
|
||
fpath:line_to(fbx, nudge_bottom_y)
|
||
else
|
||
fpath:line_to(fax, fay)
|
||
fpath:line_to(fbx, fay)
|
||
end
|
||
fpath:line_to(fbx, fby)
|
||
|
||
-- Background
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background ..
|
||
'\\iclip(' .. fpath.scale .. ', ' .. fpath.text .. ')}')
|
||
ass:append(ass_opacity(math.max(options.volume_opacity - 0.1, 0), opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:move_to(bax, bay)
|
||
ass:line_to(bbx, bay)
|
||
local half_border = options.volume_border / 2
|
||
if slider.draw_nudge then
|
||
ass:line_to(bbx, math.max(slider.nudge_y - slider.nudge_size + half_border, bay))
|
||
ass:line_to(bbx - slider.nudge_size + half_border, slider.nudge_y)
|
||
ass:line_to(bbx, slider.nudge_y + slider.nudge_size - half_border)
|
||
end
|
||
ass:line_to(bbx, bby)
|
||
ass:line_to(bax, bby)
|
||
if slider.draw_nudge then
|
||
ass:line_to(bax, slider.nudge_y + slider.nudge_size - half_border)
|
||
ass:line_to(bax + slider.nudge_size - half_border, slider.nudge_y)
|
||
ass:line_to(bax, math.max(slider.nudge_y - slider.nudge_size + half_border, bay))
|
||
end
|
||
ass:line_to(bax, bay)
|
||
ass:draw_stop()
|
||
|
||
-- Foreground
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
||
ass:append(ass_opacity(options.volume_opacity, opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:append(fpath.text)
|
||
ass:draw_stop()
|
||
|
||
-- Current volume value
|
||
local volume_string = tostring(round(state.volume * 10) / 10)
|
||
local font_size = round(((this.width * 0.6) - (#volume_string * (this.width / 20))) * options.volume_font_scale)
|
||
if fay < slider.by - slider.spacing then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. options.color_foreground_text .. '\\fn' .. config.font ..
|
||
'\\fs' .. font_size .. bold_tag .. '\\clip(' .. fpath.scale .. ', ' .. fpath.text .. ')}')
|
||
ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), opacity))
|
||
ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing)
|
||
ass:an(2)
|
||
ass:append(volume_string)
|
||
end
|
||
if fay > slider.by - slider.spacing - font_size then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. options.color_background_text ..
|
||
'\\4c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. font_size .. bold_tag ..
|
||
'\\iclip(' .. fpath.scale .. ', ' .. fpath.text .. ')}')
|
||
ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), opacity))
|
||
ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing)
|
||
ass:an(2)
|
||
ass:append(volume_string)
|
||
end
|
||
end
|
||
|
||
-- Mute button
|
||
local mute = elements.volume_mute
|
||
local icon_name = state.mute and 'volume_muted' or 'volume'
|
||
ass:new_event()
|
||
ass:append(icon(
|
||
icon_name,
|
||
mute.ax + (mute.width / 2), mute.ay + (mute.height / 2), mute.width * 0.7, -- x, y, size
|
||
0, 0, options.volume_border, -- shadow_x, shadow_y, shadow_size
|
||
'background', options.volume_opacity * opacity-- backdrop, opacity
|
||
))
|
||
return ass
|
||
end
|
||
|
||
function render_speed(this)
|
||
if not this.dragging and (elements.curtain.opacity > 0) then return end
|
||
|
||
local proximity = this:get_effective_proximity()
|
||
local opacity = this.dragging and 1 or proximity
|
||
|
||
if opacity == 0 then return end
|
||
|
||
local ass = assdraw.ass_new()
|
||
|
||
-- Coordinates
|
||
local ax = this.ax
|
||
-- local ay = this.ay + timeline.size_max - timeline:get_effective_size()
|
||
local ay = this.ay
|
||
local bx = this.bx
|
||
local by = ay + this.height
|
||
local half_width = (this.width / 2)
|
||
local half_x = ax + half_width
|
||
|
||
-- Notches
|
||
local speed_at_center = state.speed
|
||
if this.dragging then
|
||
speed_at_center = this.dragging.start_speed + this.dragging.speed_distance
|
||
speed_at_center = math.min(math.max(speed_at_center, 0.01), 100)
|
||
end
|
||
local nearest_notch_speed = round(speed_at_center / this.notch_every) * this.notch_every
|
||
local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / this.notch_every) * this.notch_spacing)
|
||
local guide_size = math.floor(this.height / 7.5)
|
||
local notch_by = by - guide_size
|
||
local notch_ay_big = ay + round(this.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(this.notches / 2)
|
||
|
||
for i = -from_to_index, from_to_index do
|
||
local notch_speed = nearest_notch_speed + (i * this.notch_every)
|
||
|
||
if notch_speed >= 0 and notch_speed <= 100 then
|
||
local notch_x = nearest_notch_x + (i * this.notch_spacing)
|
||
local notch_thickness = 1
|
||
local notch_ay = notch_ay_small
|
||
if (notch_speed % (this.notch_every * 10)) < 0.00000001 then
|
||
notch_ay = notch_ay_big
|
||
notch_thickness = 1
|
||
elseif (notch_speed % (this.notch_every * 5)) < 0.00000001 then
|
||
notch_ay = notch_ay_medium
|
||
end
|
||
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}')
|
||
ass:append(ass_opacity(math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1), opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:move_to(notch_x - notch_thickness, notch_ay)
|
||
ass:line_to(notch_x + notch_thickness, notch_ay)
|
||
ass:line_to(notch_x + notch_thickness, notch_by)
|
||
ass:line_to(notch_x - notch_thickness, notch_by)
|
||
ass:draw_stop()
|
||
end
|
||
end
|
||
|
||
-- Center guide
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}')
|
||
ass:append(ass_opacity(options.speed_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:new_event()
|
||
ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. options.color_background_text ..
|
||
'\\3c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size .. bold_tag .. '}')
|
||
ass:append(ass_opacity(options.speed_opacity, opacity))
|
||
ass:pos(half_x, ay)
|
||
ass:an(8)
|
||
ass:append(speed_text)
|
||
|
||
return ass
|
||
end
|
||
|
||
function render_menu_button(this)
|
||
local opacity = this:get_effective_proximity()
|
||
|
||
if this.width == 0 or opacity == 0 then return end
|
||
|
||
if this.proximity_raw > 0 then opacity = opacity / 2 end
|
||
|
||
local ass = assdraw.ass_new()
|
||
-- Menu button
|
||
local burger = elements.menu_button
|
||
ass:new_event()
|
||
ass:append(icon(
|
||
'menu_button',
|
||
burger.ax + (burger.width / 2), burger.ay + (burger.height / 2), burger.width, -- x, y, size
|
||
0, 0, options.menu_button_border, -- shadow_x, shadow_y, shadow_size
|
||
'background', options.menu_button_opacity * opacity-- backdrop, opacity
|
||
))
|
||
return ass
|
||
end
|
||
|
||
function render_menu(this)
|
||
local ass = assdraw.ass_new()
|
||
|
||
if this.parent_menu then
|
||
ass:merge(this.parent_menu:render())
|
||
end
|
||
|
||
-- Menu title
|
||
if this.title then
|
||
-- Background
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(this.ax, this.ay - this.item_height, this.bx, this.ay - 1)
|
||
ass:draw_stop()
|
||
|
||
-- Title
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad1\\b1\\1c&H' .. options.color_background_text ..
|
||
'\\4c&H' .. options.color_background .. '\\fn' .. config.font .. '\\fs' .. this.font_size ..
|
||
'\\q2\\clip(' .. this.ax .. ',' .. this.ay - this.item_height .. ',' .. this.bx .. ',' .. this.ay .. ')}')
|
||
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
||
ass:pos(display.width / 2, this.ay - (this.item_height * 0.5))
|
||
ass:an(5)
|
||
ass:append(this.title)
|
||
end
|
||
|
||
local scroll_area_clip = '\\clip(' .. this.ax .. ',' .. this.ay .. ',' .. this.bx .. ',' .. this.by .. ')'
|
||
|
||
for index, item in ipairs(this.items) do
|
||
local item_ay = this.ay - this.scroll_y + (this.item_height * (index - 1) + this.item_spacing * (index - 1))
|
||
local item_by = item_ay + this.item_height
|
||
local item_clip = ''
|
||
|
||
if item_by >= this.ay and item_ay <= this.by then
|
||
-- Clip items overflowing scroll area
|
||
if item_ay <= this.ay or item_by >= this.by then
|
||
item_clip = scroll_area_clip
|
||
end
|
||
|
||
local is_active = this.active_index == index
|
||
local font_color, background_color, ass_shadow, ass_shadow_color
|
||
local icon_size = this.font_size
|
||
|
||
if is_active then
|
||
font_color, background_color = options.color_foreground_text, options.color_foreground
|
||
ass_shadow, ass_shadow_color = '\\shad0', ''
|
||
else
|
||
font_color, background_color = options.color_background_text, options.color_background
|
||
ass_shadow, ass_shadow_color = '\\shad1', '\\4c&H' .. background_color
|
||
end
|
||
|
||
local has_submenu = item.items ~= nil
|
||
local hint_width = 0
|
||
if item.hint then
|
||
hint_width = text_width_estimate(item.hint, this.font_size_hint)
|
||
elseif has_submenu then
|
||
hint_width = icon_size
|
||
end
|
||
|
||
-- Background
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. background_color .. item_clip .. '}')
|
||
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(this.ax, item_ay, this.bx, item_by)
|
||
ass:draw_stop()
|
||
|
||
-- Selected highlight
|
||
if this.selected_index == index then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. item_clip .. '}')
|
||
ass:append(ass_opacity(0.1, this.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(this.ax, item_ay, this.bx, item_by)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
-- Title
|
||
if item.title then
|
||
item.ass_save_title = item.ass_save_title or item.title:gsub('([{}])', '\\%1')
|
||
local title_clip_x = (this.bx - hint_width - this.item_content_spacing)
|
||
local title_clip = '\\clip(' ..
|
||
this.ax .. ',' .. math.max(item_ay, this.ay) .. ',' ..
|
||
title_clip_x .. ',' .. math.min(item_by, this.by) .. ')'
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. font_color .. '\\4c&H' .. background_color ..
|
||
'\\fn' .. config.font .. '\\fs' .. this.font_size .. bold_tag .. title_clip .. '\\q2}')
|
||
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
||
ass:pos(this.ax + this.item_content_spacing, item_ay + (this.item_height / 2))
|
||
ass:an(4)
|
||
ass:append(item.ass_save_title)
|
||
end
|
||
|
||
-- Hint
|
||
if item.hint then
|
||
item.ass_save_hint = item.ass_save_hint or item.hint:gsub('([{}])', '\\%1')
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0' .. ass_shadow .. '\\1c&H' .. font_color .. '' .. ass_shadow_color ..
|
||
'\\fn' .. config.font .. '\\fs' .. this.font_size_hint .. bold_tag .. item_clip .. '}')
|
||
ass:append(ass_opacity(options.menu_opacity * (has_submenu and 1 or 0.5), this.opacity))
|
||
ass:pos(this.bx - this.item_content_spacing, item_ay + (this.item_height / 2))
|
||
ass:an(6)
|
||
ass:append(item.ass_save_hint)
|
||
elseif has_submenu then
|
||
ass:new_event()
|
||
ass:append(icon(
|
||
'arrow_right',
|
||
this.bx - this.item_content_spacing - (icon_size / 2), -- x
|
||
item_ay + (this.item_height / 2), -- y
|
||
icon_size, -- size
|
||
0, 0, 1, -- shadow_x, shadow_y, shadow_size
|
||
is_active and 'foreground' or 'background', this.opacity, -- backdrop, opacity
|
||
item_clip
|
||
))
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Scrollbar
|
||
if this.scroll_height > 0 then
|
||
local groove_height = this.height - 2
|
||
local thumb_height = math.max((this.height / (this.scroll_height + this.height)) * groove_height, 40)
|
||
local thumb_y = this.ay + 1 + ((this.scroll_y / this.scroll_height) * (groove_height - thumb_height))
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
||
ass:append(ass_opacity(options.menu_opacity, this.opacity * 0.8))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(this.bx - 3, thumb_y, this.bx - 1, thumb_y + thumb_height)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
return ass
|
||
end
|
||
|
||
-- MAIN RENDERING
|
||
|
||
-- Request that render() is called.
|
||
-- The render is then either executed immediately, or rate-limited if it was
|
||
-- called a small time ago.
|
||
function request_render()
|
||
if state.render_timer == nil then
|
||
state.render_timer = mp.add_timeout(0, render)
|
||
end
|
||
|
||
if not state.render_timer:is_enabled() then
|
||
local now = mp.get_time()
|
||
local timeout = state.render_delay - (now - state.render_last_time)
|
||
if timeout < 0 then
|
||
timeout = 0
|
||
end
|
||
state.render_timer.timeout = timeout
|
||
state.render_timer:resume()
|
||
end
|
||
end
|
||
|
||
function render()
|
||
state.render_last_time = mp.get_time()
|
||
|
||
-- Actual rendering
|
||
local ass = assdraw.ass_new()
|
||
|
||
for _, element in elements:ipairs() do
|
||
local result = element:maybe('render')
|
||
if result then
|
||
ass:new_event()
|
||
ass:merge(result)
|
||
end
|
||
end
|
||
|
||
-- submit
|
||
if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
|
||
return
|
||
end
|
||
|
||
osd.res_x = display.width
|
||
osd.res_y = display.height
|
||
osd.data = ass.text
|
||
osd.z = 2000
|
||
osd:update()
|
||
end
|
||
|
||
-- STATIC ELEMENTS
|
||
|
||
elements:add('window_border', Element.new({
|
||
size = nil, -- set in init
|
||
init = function(this)
|
||
this:update_size();
|
||
end,
|
||
update_size = function(this)
|
||
this.size = options.window_border_size > 0 and not state.fullormaxed and not state.border and
|
||
options.window_border_size or 0
|
||
end,
|
||
on_prop_border = function(this) this:update_size() end,
|
||
on_prop_fullormaxed = function(this) this:update_size() end,
|
||
render = function(this)
|
||
if this.size > 0 then
|
||
local ass = assdraw.ass_new()
|
||
local clip_coordinates = this.size ..
|
||
',' .. this.size .. ',' .. (display.width - this.size) .. ',' .. (display.height - this.size)
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '\\iclip(' .. clip_coordinates .. ')}')
|
||
ass:append(ass_opacity(options.window_border_opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(0, 0, display.width, display.height)
|
||
ass:draw_stop()
|
||
return ass
|
||
end
|
||
end
|
||
}))
|
||
elements:add('pause_indicator', Element.new({
|
||
base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8,
|
||
paused = state.pause,
|
||
type = options.pause_indicator,
|
||
is_manual = options.pause_indicator == 'manual',
|
||
fadeout_requested = false,
|
||
opacity = 0,
|
||
init = function(this)
|
||
mp.observe_property('pause', 'bool', function(_, paused)
|
||
if options.pause_indicator == 'flash' then
|
||
if this.paused == paused then return end
|
||
this:flash()
|
||
elseif options.pause_indicator == 'static' then
|
||
this:decide()
|
||
end
|
||
end)
|
||
end,
|
||
flash = function(this)
|
||
if not this.is_manual and this.type ~= 'flash' then return end
|
||
-- can't wait for pause property event listener to set this, because when this is used inside a binding like:
|
||
-- cycle pause; script-binding uosc/flash-pause-indicator
|
||
-- the pause event is not fired fast enough, and indicator starts rendering with old icon
|
||
this.paused = mp.get_property_native('pause')
|
||
if this.is_manual then this.type = 'flash' end
|
||
this.opacity = 1
|
||
this:tween_property('opacity', 1, 0, 0.15)
|
||
end,
|
||
-- decides whether static indicator should be visible or not
|
||
decide = function(this)
|
||
if not this.is_manual and this.type ~= 'static' then return end
|
||
this.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
|
||
if this.is_manual then this.type = 'static' end
|
||
this.opacity = this.paused and 1 or 0
|
||
request_render()
|
||
|
||
-- works around an mpv race condition bug during pause on windows builds, which cause 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,
|
||
render = function(this)
|
||
if this.opacity == 0 then return end
|
||
|
||
local ass = assdraw.ass_new()
|
||
local is_static = this.type == 'static'
|
||
|
||
-- Background fadeout
|
||
if is_static then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(0.3, this.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(0, 0, display.width, display.height)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
-- Icon
|
||
local size = round((math.min(display.width, display.height) * (is_static and 0.20 or 0.15)) / 2)
|
||
|
||
size = size + size * (1 - this.opacity)
|
||
|
||
if this.paused then
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\1c&H' .. options.color_foreground .. '\\3c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
||
ass:pos(display.width / 2, display.height / 2)
|
||
ass:draw_start()
|
||
ass:rect_cw(-size, -size, -size / 3, size)
|
||
ass:draw_stop()
|
||
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\1c&H' .. options.color_foreground .. '\\3c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
||
ass:pos(display.width / 2, display.height / 2)
|
||
ass:draw_start()
|
||
ass:rect_cw(size / 3, -size, size, size)
|
||
ass:draw_stop()
|
||
else
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord1\\1c&H' .. options.color_foreground .. '\\3c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
||
ass:pos(display.width / 2, display.height / 2)
|
||
ass:draw_start()
|
||
ass:move_to(-size * 0.6, -size)
|
||
ass:line_to(size, 0)
|
||
ass:line_to(-size * 0.6, size)
|
||
ass:draw_stop()
|
||
end
|
||
|
||
return ass
|
||
end
|
||
}))
|
||
elements:add('timeline', Element.new({
|
||
pressed = false,
|
||
size_max = 0, size_min = 0, -- set in `on_display_change` handler based on `state.fullormaxed`
|
||
size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command
|
||
font_size = 0, -- calculated in on_display_change
|
||
top_border = options.timeline_border,
|
||
get_effective_proximity = function(this)
|
||
if this.pressed or is_element_persistent('timeline') then return 1 end
|
||
if this.forced_proximity then return this.forced_proximity end
|
||
return (elements.volume_slider and elements.volume_slider.pressed) and 0 or this.proximity
|
||
end,
|
||
get_effective_size_min = function(this)
|
||
return this.size_min_override or this.size_min
|
||
end,
|
||
get_effective_size = function(this)
|
||
if elements.speed and elements.speed.dragging then return this.size_max end
|
||
local size_min = this:get_effective_size_min()
|
||
return size_min + math.ceil((this.size_max - size_min) * this:get_effective_proximity())
|
||
end,
|
||
get_effective_line_width = function(this)
|
||
return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width
|
||
end,
|
||
update_dimensions = function(this)
|
||
if state.fullormaxed then
|
||
this.size_min = options.timeline_size_min_fullscreen
|
||
this.size_max = options.timeline_size_max_fullscreen
|
||
else
|
||
this.size_min = options.timeline_size_min
|
||
this.size_max = options.timeline_size_max
|
||
end
|
||
this.font_size = math.floor(math.min((this.size_max + 60) * 0.2, this.size_max * 0.96) * options.timeline_font_scale)
|
||
this.ax = elements.window_border.size
|
||
this.ay = display.height - elements.window_border.size - this.size_max - this.top_border
|
||
this.bx = display.width - elements.window_border.size
|
||
this.by = display.height - elements.window_border.size
|
||
this.width = this.bx - this.ax
|
||
end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_prop_fullormaxed = function(this) this:update_dimensions() end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
set_from_cursor = function(this)
|
||
-- padding serves the purpose of matching cursor to timeline_style=line exactly
|
||
local padding = (options.timeline_style == 'line' and this:get_effective_line_width() or 0) / 2
|
||
local progress = math.max(0, math.min((cursor.x - this.ax - padding) / (this.width - padding * 2), 1))
|
||
mp.commandv('seek', (progress * 100), 'absolute-percent+exact')
|
||
end,
|
||
on_mbtn_left_down = function(this)
|
||
this.pressed = true
|
||
this:set_from_cursor()
|
||
end,
|
||
on_global_mbtn_left_up = function(this) this.pressed = false end,
|
||
on_global_mouse_leave = function(this) this.pressed = false end,
|
||
on_global_mouse_move = function(this)
|
||
if this.pressed then this:set_from_cursor() end
|
||
end,
|
||
on_wheel_up = function(this)
|
||
mp.commandv('seek', options.timeline_step)
|
||
end,
|
||
on_wheel_down = function(this)
|
||
mp.commandv('seek', -options.timeline_step)
|
||
end,
|
||
render = render_timeline,
|
||
}))
|
||
elements:add('top_bar', Element.new({
|
||
button_opacity = 0.8,
|
||
enabled = false,
|
||
get_effective_proximity = function(this)
|
||
if is_element_persistent('top_bar') then return 1 end
|
||
if this.forced_proximity then return this.forced_proximity end
|
||
return (elements.volume_slider and elements.volume_slider.pressed) and 0 or this.proximity
|
||
end,
|
||
decide_enabled = function(this)
|
||
if options.top_bar == 'no-border' then
|
||
this.enabled = not state.border or state.fullormaxed
|
||
elseif options.top_bar == 'always' then
|
||
this.enabled = true
|
||
else
|
||
this.enabled = false
|
||
end
|
||
this.enabled = this.enabled and (options.top_bar_controls or options.top_bar_title)
|
||
end,
|
||
update_dimensions = function(this)
|
||
this.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size
|
||
this.icon_size = round(this.size / 8)
|
||
this.spacing = math.ceil(this.size * 0.25)
|
||
this.font_size = math.floor(this.size - (this.spacing * 2))
|
||
this.button_width = round(this.size * 1.15)
|
||
this.ay = elements.window_border.size
|
||
this.bx = display.width - elements.window_border.size
|
||
this.by = this.size + elements.window_border.size
|
||
this.title_bx = this.bx - (options.top_bar_controls and (this.button_width * 3) or 0)
|
||
this.ax = options.top_bar_title and elements.window_border.size or this.title_bx
|
||
end,
|
||
on_prop_border = function(this)
|
||
this:decide_enabled()
|
||
this:update_dimensions()
|
||
end,
|
||
on_prop_fullormaxed = function(this)
|
||
this:decide_enabled()
|
||
this:update_dimensions()
|
||
end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
render = render_top_bar,
|
||
}))
|
||
if options.top_bar_controls then
|
||
elements:add('window_controls_minimize', Element.new({
|
||
update_dimensions = function(this)
|
||
this.ax = elements.top_bar.bx - (elements.top_bar.button_width * 3)
|
||
this.ay = elements.top_bar.ay
|
||
this.bx = this.ax + elements.top_bar.button_width
|
||
this.by = this.ay + elements.top_bar.size
|
||
end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_mbtn_left_down = function() mp.commandv('cycle', 'window-minimized') end
|
||
}))
|
||
elements:add('window_controls_maximize', Element.new({
|
||
update_dimensions = function(this)
|
||
this.ax = elements.top_bar.bx - (elements.top_bar.button_width * 2)
|
||
this.ay = elements.top_bar.ay
|
||
this.bx = this.ax + elements.top_bar.button_width
|
||
this.by = this.ay + elements.top_bar.size
|
||
end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_mbtn_left_down = function() mp.commandv('cycle', 'window-maximized') end
|
||
}))
|
||
elements:add('window_controls_close', Element.new({
|
||
update_dimensions = function(this)
|
||
this.ax = elements.top_bar.bx - elements.top_bar.button_width
|
||
this.ay = elements.top_bar.ay
|
||
this.bx = this.ax + elements.top_bar.button_width
|
||
this.by = this.ay + elements.top_bar.size
|
||
end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_mbtn_left_down = function() mp.commandv('quit') end
|
||
}))
|
||
end
|
||
if itable_find({'left', 'right'}, options.volume) then
|
||
elements:add('volume', Element.new({
|
||
width = nil, -- set in `on_display_change` handler based on `state.fullormaxed`
|
||
height = nil, -- set in `on_display_change` handler based on `state.fullormaxed`
|
||
margin = nil, -- set in `on_display_change` handler based on `state.fullormaxed`
|
||
get_effective_proximity = function(this)
|
||
if is_element_persistent('volume') or elements.volume_slider.pressed then return 1 end
|
||
if this.forced_proximity then return this.forced_proximity end
|
||
return elements.timeline.proximity_raw == 0 and 0 or this.proximity
|
||
end,
|
||
update_dimensions = function(this)
|
||
this.width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size
|
||
this.height = round(math.min(this.width * 8, (elements.timeline.ay - elements.top_bar.size) * 0.8))
|
||
-- Don't bother rendering this if too small
|
||
if this.height < (this.width * 2) then
|
||
this.height = 0
|
||
end
|
||
this.margin = (this.width / 2) + elements.window_border.size
|
||
this.ax = round(options.volume == 'left' and this.margin or display.width - this.margin - this.width)
|
||
this.ay = round((display.height - this.height) / 2)
|
||
this.bx = round(this.ax + this.width)
|
||
this.by = round(this.ay + this.height)
|
||
end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
render = render_volume,
|
||
}))
|
||
elements:add('volume_mute', Element.new({
|
||
width = 0,
|
||
height = 0,
|
||
on_display_change = function(this)
|
||
this.width = elements.volume.width
|
||
this.height = this.width
|
||
this.ax = elements.volume.ax
|
||
this.ay = elements.volume.by - this.height
|
||
this.bx = elements.volume.bx
|
||
this.by = elements.volume.by
|
||
end,
|
||
on_mbtn_left_down = function(this) mp.commandv('cycle', 'mute') end
|
||
}))
|
||
elements:add('volume_slider', Element.new({
|
||
pressed = false,
|
||
width = 0,
|
||
height = 0,
|
||
nudge_y = 0, -- vertical position where volume overflows 100
|
||
nudge_size = nil, -- set on resize
|
||
font_size = nil,
|
||
spacing = nil,
|
||
on_display_change = function(this)
|
||
if state.volume_max == nil or state.volume_max == 0 then return end
|
||
this.ax = elements.volume.ax
|
||
this.ay = elements.volume.ay
|
||
this.bx = elements.volume.bx
|
||
this.by = elements.volume_mute.ay
|
||
this.width = this.bx - this.ax
|
||
this.height = this.by - this.ay
|
||
this.nudge_y = this.by - round(this.height * (100 / state.volume_max))
|
||
this.nudge_size = round(elements.volume.width * 0.18)
|
||
this.draw_nudge = this.ay < this.nudge_y
|
||
this.spacing = round(this.width * 0.2)
|
||
end,
|
||
set_from_cursor = function(this)
|
||
local volume_fraction = (this.by - cursor.y - options.volume_border) / (this.height - options.volume_border)
|
||
local new_volume = math.min(math.max(volume_fraction, 0), 1) * state.volume_max
|
||
new_volume = round(new_volume / options.volume_step) * options.volume_step
|
||
if state.volume ~= new_volume then mp.commandv('set', 'volume', math.min(new_volume, state.volume_max)) end
|
||
end,
|
||
on_mbtn_left_down = function(this)
|
||
this.pressed = true
|
||
this:set_from_cursor()
|
||
end,
|
||
on_global_mbtn_left_up = function(this) this.pressed = false end,
|
||
on_global_mouse_leave = function(this) this.pressed = false end,
|
||
on_global_mouse_move = function(this)
|
||
if this.pressed then this:set_from_cursor() end
|
||
end,
|
||
on_wheel_up = function(this)
|
||
local current_rounded_volume = round(state.volume / options.volume_step) * options.volume_step
|
||
mp.commandv('set', 'volume', math.min(current_rounded_volume + options.volume_step, state.volume_max))
|
||
end,
|
||
on_wheel_down = function(this)
|
||
local current_rounded_volume = round(state.volume / options.volume_step) * options.volume_step
|
||
mp.commandv('set', 'volume', math.min(current_rounded_volume - options.volume_step, state.volume_max))
|
||
end,
|
||
}))
|
||
end
|
||
if itable_find({'center', 'bottom-bar'}, options.menu_button) then
|
||
elements:add('menu_button', Element.new({
|
||
width = 0, height = 0,
|
||
get_effective_proximity = function(this)
|
||
if menu:is_open() then return 0 end
|
||
if is_element_persistent('menu_button') then return 1 end
|
||
if elements.timeline.proximity_raw == 0 then return 0 end
|
||
if this.forced_proximity then return this.forced_proximity end
|
||
if options.menu_button == 'bottom-bar' then
|
||
local timeline_proximity = elements.timeline.forced_proximity or elements.timeline.proximity
|
||
return this.forced_proximity or math.max(this.proximity, timeline_proximity)
|
||
end
|
||
return this.proximity
|
||
end,
|
||
update_dimensions = function(this)
|
||
this.width = state.fullormaxed and options.menu_button_size_fullscreen or options.menu_button_size
|
||
this.height = this.width
|
||
|
||
if options.menu_button == 'bottom-bar' then
|
||
this.ax = 15
|
||
this.bx = this.ax + this.width
|
||
this.by = display.height - 10 - elements.window_border.size - elements.timeline.size_max -
|
||
elements.timeline.top_border
|
||
this.ay = this.by - this.height
|
||
else
|
||
this.ax = round((display.width - this.width) / 2)
|
||
this.ay = round((display.height - this.height) / 2)
|
||
this.bx = this.ax + this.width
|
||
this.by = this.ay + this.height
|
||
end
|
||
end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_mbtn_left_down = function(this)
|
||
if this.proximity_raw == 0 then
|
||
-- We delay menu opening to next tick, otherwise it gets added at
|
||
-- the end of the elements list, and the mbtn_left_down event
|
||
-- dispatcher inside which we are now will tell it to close itself.
|
||
mp.add_timeout(0.01, menu_key_binding)
|
||
end
|
||
end,
|
||
render = render_menu_button,
|
||
}))
|
||
end
|
||
if options.speed then
|
||
local function 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
|
||
|
||
elements:add('speed', Element.new({
|
||
dragging = nil,
|
||
width = 0,
|
||
height = 0,
|
||
notches = 10,
|
||
notch_every = 0.1,
|
||
font_size = nil,
|
||
get_effective_proximity = function(this)
|
||
if elements.timeline.proximity_raw == 0 then return 0 end
|
||
if is_element_persistent('speed') then return 1 end
|
||
if this.forced_proximity then return this.forced_proximity end
|
||
local timeline_proximity = elements.timeline.forced_proximity or elements.timeline.proximity
|
||
return this.forced_proximity or math.max(this.proximity, timeline_proximity)
|
||
end,
|
||
update_dimensions = function(this)
|
||
this.height = state.fullormaxed and options.speed_size_fullscreen or options.speed_size
|
||
this.width = round(this.height * 3.6)
|
||
this.notch_spacing = this.width / this.notches
|
||
this.ax = (display.width - this.width) / 2
|
||
this.by = display.height - elements.window_border.size - elements.timeline.size_max - elements.timeline.top_border
|
||
this.ay = this.by - this.height
|
||
this.bx = this.ax + this.width
|
||
this.font_size = round(this.height * 0.48 * options.speed_font_scale)
|
||
end,
|
||
set_from_cursor = function(this)
|
||
local volume_fraction = (this.by - cursor.y - options.volume_border) / (this.height - options.volume_border)
|
||
local new_volume = math.min(math.max(volume_fraction, 0), 1) * state.volume_max
|
||
new_volume = round(new_volume / options.volume_step) * options.volume_step
|
||
if state.volume ~= new_volume then mp.commandv('set', 'volume', new_volume) end
|
||
end,
|
||
on_prop_border = function(this) this:update_dimensions() end,
|
||
on_display_change = function(this) this:update_dimensions() end,
|
||
on_mbtn_left_down = function(this)
|
||
this:tween_stop() -- Stop and cleanup possible ongoing animations
|
||
this.dragging = {
|
||
start_time = mp.get_time(),
|
||
start_x = cursor.x,
|
||
distance = 0,
|
||
speed_distance = 0,
|
||
start_speed = state.speed
|
||
}
|
||
end,
|
||
on_global_mouse_move = function(this)
|
||
if not this.dragging then return end
|
||
|
||
this.dragging.distance = cursor.x - this.dragging.start_x
|
||
this.dragging.speed_distance = (-this.dragging.distance / this.notch_spacing * this.notch_every)
|
||
|
||
local speed_current = state.speed
|
||
local speed_drag_current = this.dragging.start_speed + this.dragging.speed_distance
|
||
speed_drag_current = math.min(math.max(speed_drag_current, 0.01), 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 = speed_step(speed_step_next, drag_dir_up)
|
||
end
|
||
local speed_step_prev = 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,
|
||
on_mbtn_left_up = function(this)
|
||
-- Reset speed on short clicks
|
||
if this.dragging and math.abs(this.dragging.distance) < 6 and mp.get_time() - this.dragging.start_time < 0.15 then
|
||
mp.set_property_native('speed', 1)
|
||
end
|
||
end,
|
||
on_global_mbtn_left_up = function(this)
|
||
this.dragging = nil
|
||
request_render()
|
||
end,
|
||
on_global_mouse_leave = function(this)
|
||
this.dragging = nil
|
||
request_render()
|
||
end,
|
||
on_wheel_up = function(this)
|
||
mp.set_property_native('speed', speed_step(state.speed, true))
|
||
end,
|
||
on_wheel_down = function(this)
|
||
mp.set_property_native('speed', speed_step(state.speed, false))
|
||
end,
|
||
render = render_speed,
|
||
}))
|
||
end
|
||
elements:add('curtain', Element.new({
|
||
opacity = 0,
|
||
fadeout = function(this)
|
||
this:tween_property('opacity', this.opacity, 0);
|
||
end,
|
||
fadein = function(this)
|
||
this:tween_property('opacity', this.opacity, 1);
|
||
end,
|
||
render = function(this)
|
||
if this.opacity > 0 and options.curtain_opacity > 0 then
|
||
local ass = assdraw.ass_new()
|
||
ass:new_event()
|
||
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}')
|
||
ass:append(ass_opacity(options.curtain_opacity, this.opacity))
|
||
ass:pos(0, 0)
|
||
ass:draw_start()
|
||
ass:rect_cw(0, 0, display.width, display.height)
|
||
ass:draw_stop()
|
||
return ass
|
||
end
|
||
end
|
||
}))
|
||
|
||
-- CHAPTERS SERIALIZATION
|
||
|
||
-- Parse `chapter_ranges` option into workable data structure
|
||
for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
|
||
local start_patterns, color, opacity, end_patterns = string.match(
|
||
definition,
|
||
'([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)'
|
||
)
|
||
|
||
-- Valid definition
|
||
if start_patterns then
|
||
start_patterns = start_patterns:lower()
|
||
end_patterns = end_patterns:lower()
|
||
local uses_bof = start_patterns:find('{bof}') ~= nil
|
||
local uses_eof = end_patterns:find('{eof}') ~= nil
|
||
local chapter_range = {
|
||
start_patterns = split(start_patterns, '|'),
|
||
end_patterns = split(end_patterns, '|'),
|
||
color = color,
|
||
opacity = tonumber(opacity),
|
||
ranges = {}
|
||
}
|
||
|
||
-- Filter out special keywords so we don't use them when matching titles
|
||
if uses_bof then
|
||
chapter_range.start_patterns = itable_remove(chapter_range.start_patterns, '{bof}')
|
||
end
|
||
if uses_eof and chapter_range.end_patterns then
|
||
chapter_range.end_patterns = itable_remove(chapter_range.end_patterns, '{eof}')
|
||
end
|
||
|
||
chapter_range['serialize'] = function(chapters)
|
||
chapter_range.ranges = {}
|
||
local current_range = nil
|
||
-- bof and eof should be used only once per timeline
|
||
-- eof is only used when last range is missing end
|
||
local bof_used = false
|
||
|
||
local function start_range(chapter)
|
||
-- If there is already a range started, should we append or overwrite?
|
||
-- I chose overwrite here.
|
||
current_range = {['start'] = chapter}
|
||
end
|
||
|
||
local function end_range(chapter)
|
||
current_range['end'] = chapter
|
||
chapter_range.ranges[#chapter_range.ranges + 1] = current_range
|
||
-- Mark both chapter objects
|
||
current_range['start']._uosc_used_as_range_point = true
|
||
current_range['end']._uosc_used_as_range_point = true
|
||
-- Clear for next range
|
||
current_range = nil
|
||
end
|
||
|
||
for _, chapter in ipairs(chapters) do
|
||
if type(chapter.title) == 'string' then
|
||
local lowercase_title = chapter.title:lower()
|
||
local is_end = false
|
||
local is_start = false
|
||
|
||
-- Is ending check and handling
|
||
if chapter_range.end_patterns then
|
||
for _, end_pattern in ipairs(chapter_range.end_patterns) do
|
||
is_end = is_end or lowercase_title:find(end_pattern) ~= nil
|
||
end
|
||
|
||
if is_end then
|
||
if current_range == nil and uses_bof and not bof_used then
|
||
bof_used = true
|
||
start_range({time = 0})
|
||
end
|
||
if current_range ~= nil then
|
||
end_range(chapter)
|
||
else
|
||
is_end = false
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Is start check and handling
|
||
for _, start_pattern in ipairs(chapter_range.start_patterns) do
|
||
is_start = is_start or lowercase_title:find(start_pattern) ~= nil
|
||
end
|
||
|
||
if is_start then start_range(chapter) end
|
||
end
|
||
end
|
||
|
||
-- If there is an unfinished range and range type accepts eof, use it
|
||
if current_range ~= nil and uses_eof then
|
||
end_range({time = state.duration or infinity})
|
||
end
|
||
end
|
||
|
||
state.chapter_ranges = state.chapter_ranges or {}
|
||
state.chapter_ranges[#state.chapter_ranges + 1] = chapter_range
|
||
end
|
||
end
|
||
|
||
function parse_chapters()
|
||
-- Sometimes state.duration is not initialized yet for some reason
|
||
state.duration = mp.get_property_native('duration')
|
||
|
||
local chapters = get_normalized_chapters()
|
||
|
||
if not chapters or not state.duration then return end
|
||
|
||
-- Reset custom ranges
|
||
for _, chapter_range in ipairs(state.chapter_ranges or {}) do
|
||
chapter_range.serialize(chapters)
|
||
end
|
||
|
||
for _, chapter in ipairs(chapters) do
|
||
chapter.title_wrapped, chapter.title_wrapped_width = wrap_text(chapter.title, 25)
|
||
chapter.title_wrapped = ass_escape(chapter.title_wrapped)
|
||
end
|
||
|
||
state.chapters = chapters
|
||
|
||
request_render()
|
||
end
|
||
|
||
-- CONTEXT MENU SERIALIZATION
|
||
|
||
state.context_menu_items = (function()
|
||
local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'})
|
||
local input_conf_meta, meta_error = utils.file_info(input_conf_path)
|
||
|
||
-- File doesn't exist
|
||
if not input_conf_meta or not input_conf_meta.is_file then return end
|
||
|
||
local main_menu = {items = {}, items_by_command = {}}
|
||
local submenus_by_id = {}
|
||
|
||
for line in io.lines(input_conf_path) do
|
||
local key, command, title = string.match(line, '%s*([%S]+)%s+(.-)%s+#!%s*(.-)%s*$')
|
||
if not key then
|
||
key, command, title = string.match(line, '%s*([%S]+)%s+(.-)%s+#menu:%s*(.-)%s*$')
|
||
end
|
||
if key then
|
||
local is_dummy = key:sub(1, 1) == '#'
|
||
local submenu_id = ''
|
||
local target_menu = main_menu
|
||
local title_parts = split(title or '', ' *> *')
|
||
|
||
for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
|
||
if index < #title_parts then
|
||
submenu_id = submenu_id .. title_part
|
||
|
||
if not submenus_by_id[submenu_id] then
|
||
local items = {}
|
||
submenus_by_id[submenu_id] = {items = items, items_by_command = {}}
|
||
target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
|
||
end
|
||
|
||
target_menu = submenus_by_id[submenu_id]
|
||
else
|
||
if command == 'ignore' then break end
|
||
-- If command is already in menu, just append the key to it
|
||
if target_menu.items_by_command[command] then
|
||
local hint = target_menu.items_by_command[command].hint
|
||
target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
|
||
else
|
||
local item = {
|
||
title = title_part,
|
||
hint = not is_dummy and key or nil,
|
||
value = command
|
||
}
|
||
target_menu.items_by_command[command] = item
|
||
target_menu.items[#target_menu.items + 1] = item
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
if #main_menu.items > 0 then return main_menu.items end
|
||
end)()
|
||
|
||
-- EVENT HANDLERS
|
||
|
||
function create_state_setter(name)
|
||
return function(_, value)
|
||
state[name] = value
|
||
elements:trigger('prop_' .. name, value)
|
||
request_render()
|
||
end
|
||
end
|
||
|
||
function update_cursor_position()
|
||
cursor.x, cursor.y = mp.get_mouse_pos()
|
||
-- mpv reports initial mouse position on linux as (0, 0), which always
|
||
-- displays the top bar, so we just swap this one coordinate to infinity
|
||
if cursor.x == 0 and cursor.y == 0 then
|
||
cursor.x = infinity
|
||
cursor.y = infinity
|
||
end
|
||
|
||
local dpi_scale = mp.get_property_native('display-hidpi-scale', 1.0)
|
||
dpi_scale = dpi_scale * options.ui_scale
|
||
|
||
cursor.x = cursor.x / dpi_scale
|
||
cursor.y = cursor.y / dpi_scale
|
||
|
||
update_proximities()
|
||
request_render()
|
||
end
|
||
|
||
function handle_mouse_leave()
|
||
-- Slowly fadeout elements that are currently visible
|
||
for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do
|
||
local element = elements[element_name]
|
||
if element and element.proximity > 0 then
|
||
element:tween_property('forced_proximity', element:get_effective_proximity(), 0, function()
|
||
element.forced_proximity = nil
|
||
end)
|
||
end
|
||
end
|
||
|
||
cursor.hidden = true
|
||
update_proximities()
|
||
elements:trigger('global_mouse_leave')
|
||
end
|
||
|
||
function handle_mouse_enter()
|
||
cursor.hidden = false
|
||
update_cursor_position()
|
||
tween_element_stop(state)
|
||
elements:trigger('global_mouse_enter')
|
||
end
|
||
|
||
function handle_mouse_move()
|
||
-- Handle case when we are in cursor hidden state but not left the actual
|
||
-- window (i.e. when autohide simulates mouse_leave).
|
||
if cursor.hidden then
|
||
handle_mouse_enter()
|
||
return
|
||
end
|
||
|
||
update_cursor_position()
|
||
elements:trigger('global_mouse_move')
|
||
request_render()
|
||
|
||
-- Restart timer that hides UI when mouse is autohidden
|
||
if options.autohide then
|
||
state.cursor_autohide_timer:kill()
|
||
state.cursor_autohide_timer:resume()
|
||
end
|
||
end
|
||
|
||
function navigate_directory(direction)
|
||
local path = mp.get_property_native('path')
|
||
|
||
if not path or is_protocol(path) then return end
|
||
|
||
local next_file = get_adjacent_file(path, direction, options.media_types)
|
||
|
||
if next_file then
|
||
mp.commandv('loadfile', utils.join_path(serialize_path(path).dirname, next_file))
|
||
end
|
||
end
|
||
|
||
function load_file_in_current_directory(index)
|
||
local path = mp.get_property_native('path')
|
||
|
||
if not path or is_protocol(path) then return end
|
||
|
||
local dirname = serialize_path(path).dirname
|
||
local files = get_files_in_directory(dirname, options.media_types)
|
||
|
||
if not files then return end
|
||
if index < 0 then index = #files + index + 1 end
|
||
|
||
if files[index] then
|
||
mp.commandv('loadfile', utils.join_path(dirname, files[index]))
|
||
end
|
||
end
|
||
|
||
function update_render_delay(name, fps)
|
||
if fps then
|
||
state.render_delay = 1 / fps
|
||
end
|
||
end
|
||
|
||
function observe_display_fps(name, fps)
|
||
if fps then
|
||
mp.unobserve_property(update_render_delay)
|
||
mp.unobserve_property(observe_display_fps)
|
||
mp.observe_property('display-fps', 'native', update_render_delay)
|
||
end
|
||
end
|
||
|
||
-- MENUS
|
||
|
||
function toggle_menu_with_items(items, menu_options)
|
||
menu_options = menu_options or {}
|
||
menu_options.type = 'menu'
|
||
if menu:is_open('menu') then
|
||
menu:close()
|
||
elseif items then
|
||
menu:open(items, function(command) mp.command(command) end, menu_options)
|
||
end
|
||
end
|
||
|
||
function create_self_updating_menu_opener(params)
|
||
return function()
|
||
if menu:is_open(params.type) then menu:close() return end
|
||
|
||
-- Update active index and playlist content on playlist changes
|
||
local function handle_list_prop_change(name, value)
|
||
if menu:is_open(params.type) then
|
||
local items, active_index = params.list_change_handler(name, value)
|
||
elements.menu:update({
|
||
items = items,
|
||
active_index = active_index
|
||
})
|
||
end
|
||
end
|
||
|
||
local function handle_active_prop_change(name, value)
|
||
if menu:is_open(params.type) then
|
||
elements.menu:activate_index(params.active_change_handler(name, value))
|
||
end
|
||
end
|
||
|
||
-- Items and active_index are set in the handle_prop_change callback, since adding
|
||
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
||
menu:open({}, params.selection_handler, {
|
||
type = params.type,
|
||
title = params.title,
|
||
on_open = function()
|
||
mp.observe_property(params.list_prop, 'native', handle_list_prop_change)
|
||
if params.active_prop then
|
||
mp.observe_property(params.active_prop, 'native', handle_active_prop_change)
|
||
end
|
||
end,
|
||
on_close = function()
|
||
mp.unobserve_property(handle_list_prop_change)
|
||
mp.unobserve_property(handle_active_prop_change)
|
||
end,
|
||
})
|
||
end
|
||
end
|
||
|
||
function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop)
|
||
local function tracklist_change_handler(_, tracklist)
|
||
local items = {}
|
||
local active_index = nil
|
||
|
||
for _, track in ipairs(tracklist) do
|
||
if track.type == track_type then
|
||
if track.selected then active_index = track.id end
|
||
|
||
local hint_vals = {
|
||
track.lang and track.lang:upper() or nil,
|
||
track['demux-h'] and (track['demux-w'] and track['demux-w'] .. 'x' .. track['demux-h']
|
||
or track['demux-h'] .. 'p'),
|
||
track['demux-fps'] and string.format('%.5gfps', track['demux-fps']) or nil,
|
||
track.codec,
|
||
track['audio-channels'] and track['audio-channels'] .. ' channels' or nil,
|
||
track['demux-samplerate'] and string.format('%.3gkHz', track['demux-samplerate'] / 1000) or nil,
|
||
track.forced and 'forced' or nil,
|
||
track.default and 'default' or nil,
|
||
}
|
||
local hint_vals_filtered = {}
|
||
for i = 1, #hint_vals do
|
||
if hint_vals[i] then
|
||
hint_vals_filtered[#hint_vals_filtered + 1] = hint_vals[i]
|
||
end
|
||
end
|
||
|
||
items[#items + 1] = {
|
||
title = (track.title and track.title or 'Track ' .. track.id),
|
||
hint = table.concat(hint_vals_filtered, ', '),
|
||
value = track.id
|
||
}
|
||
end
|
||
end
|
||
|
||
-- Add option to disable a subtitle track. This works for all tracks,
|
||
-- but why would anyone want to disable audio or video? Better to not
|
||
-- let people mistakenly select what is unwanted 99.999% of the time.
|
||
-- If I'm mistaken and there is an active need for this, feel free to
|
||
-- open an issue.
|
||
if track_type == 'sub' then
|
||
active_index = active_index and active_index + 1 or 1
|
||
table.insert(items, 1, {hint = 'disabled', value = nil})
|
||
end
|
||
return items, active_index
|
||
end
|
||
|
||
local function selection_handler(id)
|
||
mp.commandv('set', track_prop, id and id or 'no')
|
||
|
||
-- If subtitle track was selected, assume user also wants to see it
|
||
if id and track_type == 'sub' then
|
||
mp.commandv('set', 'sub-visibility', 'yes')
|
||
end
|
||
end
|
||
|
||
return create_self_updating_menu_opener({
|
||
title = menu_title,
|
||
type = track_type,
|
||
list_prop = 'track-list',
|
||
list_change_handler = tracklist_change_handler,
|
||
selection_handler = selection_handler
|
||
})
|
||
end
|
||
|
||
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string}
|
||
|
||
-- Opens a file navigation menu with items inside `directory_path`.
|
||
---@param directory_path string
|
||
---@param handle_select fun(path: string): nil
|
||
---@param menu_options NavigationMenuOptions
|
||
function open_file_navigation_menu(directory_path, handle_select, menu_options)
|
||
directory = serialize_path(directory_path)
|
||
menu_options = menu_options or {}
|
||
|
||
if not directory then
|
||
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
|
||
return
|
||
end
|
||
|
||
local directories, dirs_error = utils.readdir(directory.path, 'dirs')
|
||
local files, files_error = get_files_in_directory(directory.path, menu_options.allowed_types)
|
||
local is_root = not directory.dirname
|
||
|
||
if not files or not directories then
|
||
msg.error('Retrieving files from ' .. directory .. ' failed: ' .. (dirs_error or files_error or ''))
|
||
return
|
||
end
|
||
|
||
-- Files are already sorted
|
||
table.sort(directories, word_order_comparator)
|
||
|
||
-- Pre-populate items with parent directory selector if not at root
|
||
-- Each item value is a serialized path table it points to.
|
||
local items = {}
|
||
|
||
if is_root then
|
||
if state.os == 'windows' then
|
||
items[#items + 1] = {title = '..', hint = 'Drives', value = {is_drives = true, is_to_parent = true}}
|
||
end
|
||
else
|
||
local serialized = serialize_path(directory.dirname)
|
||
serialized.is_directory = true;
|
||
items[#items + 1] = {title = '..', hint = 'parent dir', value = serialized, is_to_parent = true}
|
||
end
|
||
|
||
-- Index where actual items start
|
||
local items_start_index = #items + 1
|
||
|
||
for _, dir in ipairs(directories) do
|
||
local serialized = serialize_path(utils.join_path(directory.path, dir))
|
||
if serialized then
|
||
serialized.is_directory = true
|
||
items[#items + 1] = {title = serialized.basename, value = serialized, hint = '/'}
|
||
end
|
||
end
|
||
|
||
for _, file in ipairs(files) do
|
||
local serialized = serialize_path(utils.join_path(directory.path, file))
|
||
if serialized then
|
||
serialized.is_file = true
|
||
items[#items + 1] = {title = serialized.basename, value = serialized}
|
||
end
|
||
end
|
||
|
||
menu_options.active_index = nil
|
||
|
||
for index, item in ipairs(items) do
|
||
if not item.value.is_to_parent then
|
||
if menu_options.active_path == item.value.path then
|
||
menu_options.active_index = index
|
||
end
|
||
|
||
if menu_options.selected_path == item.value.path then
|
||
menu_options.selected_index = index
|
||
end
|
||
end
|
||
end
|
||
|
||
if menu_options.selected_index == nil then
|
||
menu_options.selected_index = menu_options.active_index or math.min(items_start_index, #items)
|
||
end
|
||
|
||
local inherit_title = false
|
||
if menu_options.title == nil then
|
||
menu_options.title = directory.basename .. '/'
|
||
else
|
||
inherit_title = true
|
||
end
|
||
|
||
menu:open(items, function(path)
|
||
local inheritable_options = {
|
||
type = menu_options.type,
|
||
title = inherit_title and menu_options.title or nil,
|
||
active_path = menu_options.active_path,
|
||
selected_path = directory.path
|
||
}
|
||
|
||
if path.is_drives then
|
||
open_drives_menu(function(drive)
|
||
open_file_navigation_menu(drive, handle_select, inheritable_options)
|
||
end, {type = inheritable_options.type, title = inheritable_options.title, selected_path = directory.path})
|
||
return
|
||
end
|
||
|
||
if path.is_directory then
|
||
open_file_navigation_menu(path.path, handle_select, inheritable_options)
|
||
else
|
||
handle_select(path.path)
|
||
menu:close()
|
||
end
|
||
end, menu_options)
|
||
end
|
||
|
||
-- Opens a file navigation menu with Windows drives as items.
|
||
---@param handle_select fun(path: string): nil
|
||
---@param menu_options? NavigationMenuOptions
|
||
function open_drives_menu(handle_select, menu_options)
|
||
menu_options = menu_options or {}
|
||
local process = mp.command_native({
|
||
name = 'subprocess',
|
||
capture_stdout = true,
|
||
args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
|
||
})
|
||
local items = {}
|
||
|
||
if process.status == 0 then
|
||
for _, value in ipairs(split(process.stdout, '\n')) do
|
||
local drive = string.match(value, 'Name=([A-Z]:)')
|
||
if drive then
|
||
local drive_path = normalize_path(drive)
|
||
items[#items + 1] = {title = drive, hint = 'Drive', value = drive_path}
|
||
if menu_options.selected_path == drive_path then
|
||
menu_options.selected_index = #items
|
||
end
|
||
end
|
||
end
|
||
else
|
||
msg.error(process.stderr)
|
||
end
|
||
|
||
if not menu_options.title then
|
||
menu_options.title = 'Drives'
|
||
end
|
||
|
||
menu:open(items, handle_select, menu_options)
|
||
end
|
||
|
||
-- VALUE SERIALIZATION/NORMALIZATION
|
||
|
||
options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1)
|
||
options.timeline_chapters = itable_find({'dots', 'lines', 'lines-top', 'lines-bottom'}, options.timeline_chapters) and
|
||
options.timeline_chapters or 'never'
|
||
options.media_types = split(options.media_types, ' *, *')
|
||
options.subtitle_types = split(options.subtitle_types, ' *, *')
|
||
options.stream_quality_options = split(options.stream_quality_options, ' *, *')
|
||
options.timeline_cached_ranges = (function()
|
||
if options.timeline_cached_ranges == '' or options.timeline_cached_ranges == 'no' then return nil end
|
||
local parts = split(options.timeline_cached_ranges, ':')
|
||
return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil
|
||
end)()
|
||
for _, name in ipairs({'timeline', 'volume', 'top_bar', 'speed'}) do
|
||
local option_name = name .. '_persistency'
|
||
local flags = {}
|
||
for _, state in ipairs(split(options[option_name], ' *, *')) do
|
||
flags[state] = true
|
||
end
|
||
|
||
---@diagnostic disable-next-line: assign-type-mismatch
|
||
options[option_name] = flags
|
||
end
|
||
|
||
-- HOOKS
|
||
mp.register_event('file-loaded', parse_chapters)
|
||
mp.observe_property('playback-time', 'number', function(name, val)
|
||
state.time = val
|
||
update_human_times()
|
||
request_render()
|
||
end)
|
||
mp.observe_property('duration', 'number', function(name, val)
|
||
state.duration = val
|
||
update_human_times()
|
||
request_render()
|
||
end)
|
||
mp.observe_property('track-list', 'native', function(name, value)
|
||
-- checks if the file is audio only (mp3, etc)
|
||
local has_audio = false
|
||
local has_video = false
|
||
local is_image = false
|
||
for _, track in ipairs(value) do
|
||
if track.type == 'audio' then has_audio = true end
|
||
if track.type == 'video' then
|
||
is_image = track.image
|
||
if not is_image and not track.albumart then
|
||
has_video = true
|
||
end
|
||
end
|
||
end
|
||
state.is_audio = not has_video and has_audio
|
||
state.is_image = is_image
|
||
state.has_audio = has_audio
|
||
state.has_video = has_video
|
||
end)
|
||
mp.observe_property('chapter-list', 'native', parse_chapters)
|
||
mp.observe_property('border', 'bool', create_state_setter('border'))
|
||
mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a'))
|
||
mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b'))
|
||
mp.observe_property('media-title', 'string', create_state_setter('media_title'))
|
||
mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos'))
|
||
mp.observe_property('playlist-count', 'number', create_state_setter('playlist_count'))
|
||
mp.observe_property('fullscreen', 'bool', function(_, value)
|
||
state.fullscreen = value
|
||
state.fullormaxed = state.fullscreen or state.maximized
|
||
update_display_dimensions()
|
||
elements:trigger('prop_fullscreen', value)
|
||
elements:trigger('prop_fullormaxed', state.fullormaxed)
|
||
end)
|
||
mp.observe_property('window-maximized', 'bool', function(_, value)
|
||
state.maximized = value
|
||
state.fullormaxed = state.fullscreen or state.maximized
|
||
update_display_dimensions()
|
||
elements:trigger('prop_maximized', value)
|
||
elements:trigger('prop_fullormaxed', state.fullormaxed)
|
||
end)
|
||
mp.observe_property('idle-active', 'bool', create_state_setter('idle'))
|
||
mp.observe_property('speed', 'number', create_state_setter('speed'))
|
||
mp.observe_property('pause', 'bool', create_state_setter('pause'))
|
||
mp.observe_property('volume', 'number', create_state_setter('volume'))
|
||
mp.observe_property('volume-max', 'number', create_state_setter('volume_max'))
|
||
mp.observe_property('mute', 'bool', create_state_setter('mute'))
|
||
mp.observe_property('osd-dimensions', 'native', function(name, val)
|
||
update_display_dimensions()
|
||
request_render()
|
||
end)
|
||
mp.observe_property('display-hidpi-scale', 'native', update_display_dimensions)
|
||
mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
||
if cache_state == nil then
|
||
state.cached_ranges = nil
|
||
return
|
||
end
|
||
local cache_ranges = cache_state['seekable-ranges']
|
||
state.cached_ranges = #cache_ranges > 0 and cache_ranges or nil
|
||
request_render()
|
||
end)
|
||
mp.observe_property('display-fps', 'native', observe_display_fps)
|
||
mp.observe_property('estimated-display-fps', 'native', update_render_delay)
|
||
|
||
-- CONTROLS
|
||
|
||
-- Mouse movement key binds
|
||
local base_keybinds = {
|
||
{'mouse_move', handle_mouse_move},
|
||
{'mouse_leave', handle_mouse_leave},
|
||
{'mouse_enter', handle_mouse_enter},
|
||
}
|
||
if options.pause_on_click_shorter_than > 0 then
|
||
-- Cycles pause when click is shorter than `options.pause_on_click_shorter_than`
|
||
-- while filtering out double clicks.
|
||
local duration_seconds = options.pause_on_click_shorter_than / 1000
|
||
local last_down_event;
|
||
local click_timer = mp.add_timeout(duration_seconds, function()
|
||
mp.command('cycle pause')
|
||
end);
|
||
click_timer:kill()
|
||
base_keybinds[#base_keybinds + 1] = {'mbtn_left', function()
|
||
if mp.get_time() - last_down_event < duration_seconds then
|
||
click_timer:resume()
|
||
end
|
||
end, function()
|
||
if click_timer:is_enabled() then
|
||
click_timer:kill()
|
||
last_down_event = 0
|
||
else
|
||
last_down_event = mp.get_time()
|
||
end
|
||
end
|
||
}
|
||
end
|
||
mp.set_key_bindings(base_keybinds, 'mouse_movement', 'force')
|
||
mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor')
|
||
|
||
-- Context based key bind groups
|
||
|
||
forced_key_bindings = (function()
|
||
local function create_mouse_event_dispatcher(name)
|
||
return function(...)
|
||
for _, element in pairs(elements) do
|
||
if element.proximity_raw == 0 then
|
||
element:trigger(name, ...)
|
||
end
|
||
element:trigger('global_' .. name, ...)
|
||
end
|
||
end
|
||
end
|
||
|
||
mp.set_key_bindings({
|
||
{'mbtn_left', create_mouse_event_dispatcher('mbtn_left_up'), create_mouse_event_dispatcher('mbtn_left_down')},
|
||
{'mbtn_left_dbl', 'ignore'},
|
||
}, 'mbtn_left', 'force')
|
||
mp.set_key_bindings({
|
||
{'wheel_up', create_mouse_event_dispatcher('wheel_up')},
|
||
{'wheel_down', create_mouse_event_dispatcher('wheel_down')},
|
||
}, 'wheel', 'force')
|
||
|
||
local groups = {}
|
||
for _, group in ipairs({'mbtn_left', 'wheel'}) do
|
||
groups[group] = {
|
||
is_enabled = false,
|
||
enable = function(this)
|
||
if this.is_enabled then return end
|
||
this.is_enabled = true
|
||
mp.enable_key_bindings(group)
|
||
end,
|
||
disable = function(this)
|
||
if not this.is_enabled then return end
|
||
this.is_enabled = false
|
||
mp.disable_key_bindings(group)
|
||
end,
|
||
}
|
||
end
|
||
return groups
|
||
end)()
|
||
|
||
-- KEY BINDABLE FEATURES
|
||
|
||
mp.add_key_binding(nil, 'peek-timeline', function()
|
||
if elements.timeline.proximity > 0.5 then
|
||
elements.timeline:tween_property('proximity', elements.timeline.proximity, 0)
|
||
else
|
||
elements.timeline:tween_property('proximity', elements.timeline.proximity, 1)
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'toggle-progress', function()
|
||
local timeline = elements.timeline
|
||
if timeline.size_min_override then
|
||
timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function()
|
||
timeline.size_min_override = nil
|
||
end)
|
||
else
|
||
timeline:tween_property('size_min_override', timeline.size_min, 0)
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'flash-timeline', function()
|
||
elements.timeline:flash()
|
||
end)
|
||
mp.add_key_binding(nil, 'flash-top-bar', function()
|
||
elements.top_bar:flash()
|
||
end)
|
||
mp.add_key_binding(nil, 'flash-volume', function()
|
||
if elements.volume then elements.volume:flash() end
|
||
end)
|
||
mp.add_key_binding(nil, 'flash-speed', function()
|
||
if elements.speed then elements.speed:flash() end
|
||
end)
|
||
mp.add_key_binding(nil, 'flash-pause-indicator', function()
|
||
elements.pause_indicator:flash()
|
||
end)
|
||
mp.add_key_binding(nil, 'decide-pause-indicator', function()
|
||
elements.pause_indicator:decide()
|
||
end)
|
||
function menu_key_binding() toggle_menu_with_items(state.context_menu_items) end
|
||
mp.add_key_binding(nil, 'menu', menu_key_binding)
|
||
mp.register_script_message('show-submenu', function(name)
|
||
local path = split(name, ' *>+ *')
|
||
local items = state.context_menu_items
|
||
local last_menu_title = nil
|
||
|
||
if not items or #items < 1 then
|
||
msg.error('Can\'t find submenu, context menu is empty.')
|
||
return
|
||
end
|
||
|
||
while #path > 0 do
|
||
local menu_title = path[1]
|
||
last_menu_title = menu_title
|
||
path = itable_slice(path, 2)
|
||
local _, submenu_item = itable_find(items, function(_, item) return item.title == menu_title end)
|
||
|
||
if not submenu_item then
|
||
msg.error('Can\'t find submenu: ' .. menu_title)
|
||
return
|
||
end
|
||
|
||
items = submenu_item.items or {}
|
||
end
|
||
|
||
if items then toggle_menu_with_items(items, {title = last_menu_title, selected_index = 1}) end
|
||
end)
|
||
mp.add_key_binding(nil, 'load-subtitles', function()
|
||
if menu:is_open('load-subtitles') then menu:close() return end
|
||
|
||
local path = mp.get_property_native('path') --[[@as string|nil|false]]
|
||
if path then
|
||
if is_protocol(path) then
|
||
path = false
|
||
else
|
||
local serialized_path = serialize_path(path)
|
||
path = serialized_path ~= nil and serialized_path.dirname or false
|
||
end
|
||
end
|
||
if not path then
|
||
path = os.getenv('HOME') --[[@as string]]
|
||
end
|
||
open_file_navigation_menu(
|
||
path,
|
||
function(path) mp.commandv('sub-add', path) end,
|
||
{
|
||
type = 'load-subtitles',
|
||
title = 'Load subtitles',
|
||
allowed_types = options.subtitle_types --[[@as table]]
|
||
}
|
||
)
|
||
end)
|
||
mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener('Subtitles', 'sub', 'sid'))
|
||
mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener('Audio', 'audio', 'aid'))
|
||
mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener('Video', 'video', 'vid'))
|
||
mp.add_key_binding(nil, 'playlist', create_self_updating_menu_opener({
|
||
title = 'Playlist',
|
||
type = 'playlist',
|
||
list_prop = 'playlist',
|
||
list_change_handler = function(_, playlist)
|
||
local items = {}
|
||
for index, item in ipairs(playlist) do
|
||
local is_url = item.filename:find('://')
|
||
local item_title = type(item.title) == 'string' and #item.title > 0 and item.title or false
|
||
items[index] = {
|
||
title = item_title or (is_url and item.filename or serialize_path(item.filename).basename),
|
||
hint = tostring(index),
|
||
value = index
|
||
}
|
||
end
|
||
return items
|
||
end,
|
||
active_prop = 'playlist-pos-1',
|
||
active_change_handler = function(_, playlist_pos)
|
||
return playlist_pos
|
||
end,
|
||
selection_handler = function(index)
|
||
mp.commandv('set', 'playlist-pos-1', tostring(index))
|
||
end
|
||
}))
|
||
mp.add_key_binding(nil, 'chapters', create_self_updating_menu_opener({
|
||
title = 'Chapters',
|
||
type = 'chapters',
|
||
list_prop = 'chapter-list',
|
||
list_change_handler = function(_, _)
|
||
local items = {}
|
||
local chapters = get_normalized_chapters()
|
||
|
||
for index, chapter in ipairs(chapters) do
|
||
items[#items + 1] = {
|
||
title = chapter.title or '',
|
||
hint = mp.format_time(chapter.time),
|
||
value = chapter.time
|
||
}
|
||
end
|
||
return items
|
||
end,
|
||
active_prop = 'playback-time',
|
||
active_change_handler = function(_, playback_time)
|
||
-- Select first chapter from the end with time lower
|
||
-- than current playing position.
|
||
local position = playback_time
|
||
if not position then return nil end
|
||
local items = elements.menu.items
|
||
for index = #items, 1, -1 do
|
||
if position >= items[index].value then return index end
|
||
end
|
||
end,
|
||
selection_handler = function(time)
|
||
mp.commandv('seek', tostring(time), 'absolute')
|
||
end
|
||
}))
|
||
mp.add_key_binding(nil, 'show-in-directory', function()
|
||
local path = mp.get_property_native('path')
|
||
|
||
-- Ignore URLs
|
||
if not path or is_protocol(path) then return end
|
||
|
||
path = normalize_path(path)
|
||
|
||
if state.os == 'windows' then
|
||
utils.subprocess_detached({args = {'explorer', '/select,', path}, cancellable = false})
|
||
elseif state.os == 'macos' then
|
||
utils.subprocess_detached({args = {'open', '-R', path}, cancellable = false})
|
||
elseif state.os == 'linux' then
|
||
local result = utils.subprocess({args = {'nautilus', path}, cancellable = false})
|
||
|
||
-- Fallback opens the folder with xdg-open instead
|
||
if result.status ~= 0 then
|
||
utils.subprocess({args = {'xdg-open', serialize_path(path).dirname}, cancellable = false})
|
||
end
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'stream-quality', function()
|
||
if menu:is_open('stream-quality') then menu:close() return end
|
||
|
||
local ytdl_format = mp.get_property_native('ytdl-format')
|
||
local active_index = nil
|
||
local formats = {}
|
||
|
||
for index, height in ipairs(options.stream_quality_options) do
|
||
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
|
||
formats[#formats + 1] = {
|
||
title = height .. 'p',
|
||
value = format
|
||
}
|
||
if format == ytdl_format then active_index = index end
|
||
end
|
||
|
||
menu:open(formats, function(format)
|
||
mp.set_property('ytdl-format', format)
|
||
|
||
-- Reload the video to apply new format
|
||
-- This is taken from https://github.com/jgreco/mpv-youtube-quality
|
||
-- which is in turn taken from https://github.com/4e6/mpv-reload/
|
||
-- Dunno if playlist_pos shenanigans below are necessary.
|
||
local playlist_pos = mp.get_property_number('playlist-pos')
|
||
local duration = mp.get_property_native('duration')
|
||
local time_pos = mp.get_property('time-pos')
|
||
|
||
mp.set_property_number('playlist-pos', playlist_pos)
|
||
|
||
-- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero
|
||
-- duration property. When reloading VOD, to keep the current time position
|
||
-- we should provide offset from the start. Stream doesn't have fixed start.
|
||
-- Decent choice would be to reload stream from it's current 'live' positon.
|
||
-- That's the reason we don't pass the offset when reloading streams.
|
||
if duration and duration > 0 then
|
||
local function seeker()
|
||
mp.commandv('seek', time_pos, 'absolute')
|
||
mp.unregister_event(seeker)
|
||
end
|
||
mp.register_event('file-loaded', seeker)
|
||
end
|
||
end, {
|
||
type = 'stream-quality',
|
||
title = 'Stream quality',
|
||
active_index = active_index,
|
||
})
|
||
end)
|
||
mp.add_key_binding(nil, 'open-file', function()
|
||
if menu:is_open('open-file') then menu:close() return end
|
||
|
||
local path = mp.get_property_native('path')
|
||
local directory
|
||
local active_file
|
||
|
||
if path == nil or is_protocol(path) then
|
||
local serialized = serialize_path(mp.command_native({'expand-path', options.default_directory}))
|
||
if serialized then
|
||
directory = serialized.path
|
||
active_file = nil
|
||
end
|
||
else
|
||
local serialized = serialize_path(path)
|
||
if serialized then
|
||
directory = serialized.dirname
|
||
active_file = serialized.path
|
||
end
|
||
end
|
||
|
||
if not directory then
|
||
msg.error('Couldn\'t serialize path "' .. path .. '".')
|
||
return
|
||
end
|
||
|
||
-- Update selected file in directory navigation menu
|
||
local function handle_file_loaded()
|
||
if menu:is_open('open-file') then
|
||
local path = normalize_path(mp.get_property_native('path'))
|
||
elements.menu:activate_value(path)
|
||
elements.menu:select_value(path)
|
||
end
|
||
end
|
||
|
||
open_file_navigation_menu(
|
||
directory,
|
||
function(path) mp.commandv('loadfile', path) end,
|
||
{
|
||
type = 'open-file',
|
||
allowed_types = options.media_types --[[@as table]] ,
|
||
active_path = active_file,
|
||
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
|
||
on_close = function() mp.unregister_event(handle_file_loaded) end,
|
||
}
|
||
)
|
||
end)
|
||
mp.add_key_binding(nil, 'next', function()
|
||
if mp.get_property_native('playlist-count') > 1 then
|
||
mp.command('playlist-next')
|
||
else
|
||
navigate_directory('forward')
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'prev', function()
|
||
if mp.get_property_native('playlist-count') > 1 then
|
||
mp.command('playlist-prev')
|
||
else
|
||
navigate_directory('backward')
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'next-file', function() navigate_directory('forward') end)
|
||
mp.add_key_binding(nil, 'prev-file', function() navigate_directory('backward') end)
|
||
mp.add_key_binding(nil, 'first', function()
|
||
if mp.get_property_native('playlist-count') > 1 then
|
||
mp.commandv('set', 'playlist-pos-1', '1')
|
||
else
|
||
load_file_in_current_directory(1)
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'last', function()
|
||
local playlist_count = mp.get_property_native('playlist-count')
|
||
if playlist_count > 1 then
|
||
mp.commandv('set', 'playlist-pos-1', tostring(playlist_count))
|
||
else
|
||
load_file_in_current_directory(-1)
|
||
end
|
||
end)
|
||
mp.add_key_binding(nil, 'first-file', function() load_file_in_current_directory(1) end)
|
||
mp.add_key_binding(nil, 'last-file', function() load_file_in_current_directory(-1) end)
|
||
mp.add_key_binding(nil, 'delete-file-next', function()
|
||
local playlist_count = mp.get_property_native('playlist-count')
|
||
|
||
local next_file = nil
|
||
|
||
local path = mp.get_property_native('path')
|
||
local is_local_file = path and not is_protocol(path)
|
||
|
||
if is_local_file then
|
||
path = normalize_path(path)
|
||
|
||
if menu:is_open('open-file') then
|
||
elements.menu:delete_value(path)
|
||
end
|
||
end
|
||
|
||
if playlist_count > 1 then
|
||
mp.commandv('playlist-remove', 'current')
|
||
else
|
||
if is_local_file then
|
||
next_file = get_adjacent_file(path, 'forward', options.media_types)
|
||
end
|
||
|
||
if next_file then
|
||
mp.commandv('loadfile', next_file)
|
||
else
|
||
mp.commandv('stop')
|
||
end
|
||
end
|
||
|
||
if is_local_file then delete_file(path) end
|
||
end)
|
||
mp.add_key_binding(nil, 'delete-file-quit', function()
|
||
local path = mp.get_property_native('path')
|
||
mp.command('stop')
|
||
if path and not is_protocol(path) then delete_file(normalize_path(path)) end
|
||
mp.command('quit')
|
||
end)
|
||
mp.add_key_binding(nil, 'open-config-directory', function()
|
||
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
|
||
local config = serialize_path(config_path)
|
||
|
||
if config then
|
||
local args
|
||
|
||
if state.os == 'windows' then
|
||
args = {'explorer', '/select,', config.path}
|
||
elseif state.os == 'macos' then
|
||
args = {'open', '-R', config.path}
|
||
elseif state.os == 'linux' then
|
||
args = {'xdg-open', config.dirname}
|
||
end
|
||
|
||
utils.subprocess_detached({args = args, cancellable = false})
|
||
else
|
||
msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
|
||
end
|
||
end)
|