4709 lines
161 KiB
Lua
4709 lines
161 KiB
Lua
|
--[[ uosc 4.0.1 - 2022-Sep-24 | https://github.com/tomasklaen/uosc ]]
|
|||
|
local uosc_version = '4.0.1'
|
|||
|
|
|||
|
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
|
|||
|
local quarter_pi_sin = math.sin(math.pi / 4)
|
|||
|
|
|||
|
--[[ BASE HELPERS ]]
|
|||
|
|
|||
|
---@param number number
|
|||
|
function round(number) return math.floor(number + 0.5) end
|
|||
|
|
|||
|
---@param min number
|
|||
|
---@param value number
|
|||
|
---@param max number
|
|||
|
function clamp(min, value, max) return math.max(min, math.min(value, max)) end
|
|||
|
|
|||
|
---@param rgba string `rrggbb` or `rrggbbaa` hex string.
|
|||
|
function serialize_rgba(rgba)
|
|||
|
local a = rgba:sub(7, 8)
|
|||
|
return {
|
|||
|
color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2),
|
|||
|
opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1),
|
|||
|
}
|
|||
|
end
|
|||
|
|
|||
|
---@param str string
|
|||
|
---@param pattern string
|
|||
|
---@return string[]
|
|||
|
function split(str, pattern)
|
|||
|
local list = {}
|
|||
|
local full_pattern = '(.-)' .. pattern
|
|||
|
local last_end = 1
|
|||
|
local start_index, end_index, capture = str:find(full_pattern, 1)
|
|||
|
while start_index do
|
|||
|
list[#list + 1] = capture
|
|||
|
last_end = end_index + 1
|
|||
|
start_index, end_index, capture = str:find(full_pattern, last_end)
|
|||
|
end
|
|||
|
if last_end <= (#str + 1) then
|
|||
|
capture = str:sub(last_end)
|
|||
|
list[#list + 1] = capture
|
|||
|
end
|
|||
|
return list
|
|||
|
end
|
|||
|
|
|||
|
---@param itable table
|
|||
|
---@param value any
|
|||
|
---@return integer|nil
|
|||
|
function itable_index_of(itable, value)
|
|||
|
for index, item in ipairs(itable) do
|
|||
|
if item == value then return index end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param itable table
|
|||
|
---@param compare fun(value: any, index: number)
|
|||
|
---@param from_end? boolean Search from the end of the table.
|
|||
|
---@return number|nil index
|
|||
|
---@return any|nil value
|
|||
|
function itable_find(itable, compare, from_end)
|
|||
|
local from, to, step = from_end and #itable or 1, from_end and 1 or #itable, from_end and -1 or 1
|
|||
|
for index = from, to, step do
|
|||
|
if compare(itable[index], index) then return index, itable[index] end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param itable table
|
|||
|
---@param decider fun(value: any, index: number)
|
|||
|
function itable_filter(itable, decider)
|
|||
|
local filtered = {}
|
|||
|
for index, value in ipairs(itable) do
|
|||
|
if decider(value, index) then filtered[#filtered + 1] = value end
|
|||
|
end
|
|||
|
return filtered
|
|||
|
end
|
|||
|
|
|||
|
---@param itable table
|
|||
|
---@param value any
|
|||
|
function itable_remove(itable, value)
|
|||
|
return itable_filter(itable, function(item) return item ~= value end)
|
|||
|
end
|
|||
|
|
|||
|
---@param itable table
|
|||
|
---@param start_pos? integer
|
|||
|
---@param end_pos? integer
|
|||
|
function itable_slice(itable, start_pos, end_pos)
|
|||
|
start_pos = start_pos and start_pos or 1
|
|||
|
end_pos = end_pos and end_pos or #itable
|
|||
|
|
|||
|
if end_pos < 0 then end_pos = #itable + end_pos + 1 end
|
|||
|
if start_pos < 0 then start_pos = #itable + start_pos + 1 end
|
|||
|
|
|||
|
local new_table = {}
|
|||
|
for index, value in ipairs(itable) do
|
|||
|
if index >= start_pos and index <= end_pos then
|
|||
|
new_table[#new_table + 1] = value
|
|||
|
end
|
|||
|
end
|
|||
|
return new_table
|
|||
|
end
|
|||
|
|
|||
|
---@generic T
|
|||
|
---@param a T[]|nil
|
|||
|
---@param b T[]|nil
|
|||
|
---@return T[]
|
|||
|
function itable_join(a, b)
|
|||
|
local result = {}
|
|||
|
if a then for _, value in ipairs(a) do result[#result + 1] = value end end
|
|||
|
if b then for _, value in ipairs(b) do result[#result + 1] = value end end
|
|||
|
return result
|
|||
|
end
|
|||
|
|
|||
|
---@param target any[]
|
|||
|
---@param source any[]
|
|||
|
function itable_append(target, source)
|
|||
|
for _, value in ipairs(source) do target[#target + 1] = value end
|
|||
|
return target
|
|||
|
end
|
|||
|
|
|||
|
---@param target any[]
|
|||
|
---@param source any[]
|
|||
|
---@param props? string[]
|
|||
|
function table_assign(target, source, props)
|
|||
|
if props then
|
|||
|
for _, name in ipairs(props) do target[name] = source[name] end
|
|||
|
else
|
|||
|
for prop, value in pairs(source) do target[prop] = value end
|
|||
|
end
|
|||
|
return target
|
|||
|
end
|
|||
|
|
|||
|
---@generic T
|
|||
|
---@param table T
|
|||
|
---@return T
|
|||
|
function table_shallow_copy(table)
|
|||
|
local result = {}
|
|||
|
for key, value in pairs(table) do result[key] = value end
|
|||
|
return result
|
|||
|
end
|
|||
|
|
|||
|
--[[ OPTIONS ]]
|
|||
|
|
|||
|
local defaults = {
|
|||
|
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 = 'paused',
|
|||
|
timeline_opacity = 0.9,
|
|||
|
timeline_border = 1,
|
|||
|
timeline_step = 5,
|
|||
|
timeline_chapters_opacity = 0.8,
|
|||
|
|
|||
|
controls = 'menu,gap,subtitles,<has_many_audio>audio,<stream>stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen',
|
|||
|
controls_size = 32,
|
|||
|
controls_size_fullscreen = 40,
|
|||
|
controls_margin = 8,
|
|||
|
controls_spacing = 2,
|
|||
|
controls_persistency = '',
|
|||
|
|
|||
|
volume = 'right',
|
|||
|
volume_size = 40,
|
|||
|
volume_size_fullscreen = 52,
|
|||
|
volume_persistency = '',
|
|||
|
volume_opacity = 0.9,
|
|||
|
volume_border = 1,
|
|||
|
volume_step = 1,
|
|||
|
|
|||
|
speed_persistency = '',
|
|||
|
speed_opacity = 0.6,
|
|||
|
speed_step = 0.1,
|
|||
|
speed_step_is_factor = false,
|
|||
|
|
|||
|
menu_item_height = 36,
|
|||
|
menu_item_height_fullscreen = 50,
|
|||
|
menu_min_width = 260,
|
|||
|
menu_min_width_fullscreen = 360,
|
|||
|
menu_opacity = 1,
|
|||
|
menu_parent_opacity = 0.4,
|
|||
|
|
|||
|
top_bar = 'no-border',
|
|||
|
top_bar_size = 40,
|
|||
|
top_bar_size_fullscreen = 46,
|
|||
|
top_bar_persistency = '',
|
|||
|
top_bar_controls = true,
|
|||
|
top_bar_title = true,
|
|||
|
top_bar_title_opacity = 0.8,
|
|||
|
|
|||
|
window_border_size = 1,
|
|||
|
window_border_opacity = 0.8,
|
|||
|
|
|||
|
autoload = false,
|
|||
|
shuffle = false,
|
|||
|
|
|||
|
ui_scale = 1,
|
|||
|
font_scale = 1,
|
|||
|
text_border = 1.2,
|
|||
|
pause_on_click_shorter_than = 0,
|
|||
|
flash_duration = 1000,
|
|||
|
proximity_in = 40,
|
|||
|
proximity_out = 120,
|
|||
|
foreground = 'ffffff',
|
|||
|
foreground_text = '000000',
|
|||
|
background = '000000',
|
|||
|
background_text = 'ffffff',
|
|||
|
total_time = false,
|
|||
|
time_precision = 0,
|
|||
|
font_bold = false,
|
|||
|
autohide = false,
|
|||
|
buffered_time_threshold = 60,
|
|||
|
pause_indicator = 'flash',
|
|||
|
curtain_opacity = 0.5,
|
|||
|
stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
|
|||
|
media_types = '3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m',
|
|||
|
subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
|
|||
|
font_height_to_letter_width_ratio = 0.5,
|
|||
|
default_directory = '~/',
|
|||
|
chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
|
|||
|
chapter_range_patterns = 'openings:オープニング;endings:エンディング',
|
|||
|
}
|
|||
|
local options = table_shallow_copy(defaults)
|
|||
|
opt.read_options(options, 'uosc')
|
|||
|
-- Normalize values
|
|||
|
options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1)
|
|||
|
options.foreground = serialize_rgba(options.foreground).color
|
|||
|
options.foreground_text = serialize_rgba(options.foreground_text).color
|
|||
|
options.background = serialize_rgba(options.background).color
|
|||
|
options.background_text = serialize_rgba(options.background_text).color
|
|||
|
if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end
|
|||
|
-- Ensure required environment configuration
|
|||
|
if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
|
|||
|
|
|||
|
--[[ CONFIG ]]
|
|||
|
|
|||
|
local config = {
|
|||
|
version = uosc_version,
|
|||
|
-- 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'),
|
|||
|
media_types = split(options.media_types, ' *, *'),
|
|||
|
subtitle_types = split(options.subtitle_types, ' *, *'),
|
|||
|
stream_quality_options = split(options.stream_quality_options, ' *, *'),
|
|||
|
menu_items = (function()
|
|||
|
local input_conf_property = mp.get_property_native('input-conf');
|
|||
|
local input_conf_path = mp.command_native({
|
|||
|
'expand-path', input_conf_property == '' and '~~/input.conf' or input_conf_property,
|
|||
|
})
|
|||
|
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 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 by_id[submenu_id] then
|
|||
|
local items = {}
|
|||
|
by_id[submenu_id] = {items = items, items_by_command = {}}
|
|||
|
target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
|
|||
|
end
|
|||
|
|
|||
|
target_menu = 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
|
|||
|
else
|
|||
|
-- Default context menu
|
|||
|
return {
|
|||
|
{title = 'Open file', value = 'script-binding uosc/open-file'},
|
|||
|
{title = 'Playlist', value = 'script-binding uosc/playlist'},
|
|||
|
{title = 'Chapters', value = 'script-binding uosc/chapters'},
|
|||
|
{title = 'Subtitle tracks', value = 'script-binding uosc/subtitles'},
|
|||
|
{title = 'Audio tracks', value = 'script-binding uosc/audio'},
|
|||
|
{title = 'Stream quality', value = 'script-binding uosc/stream-quality'},
|
|||
|
{title = 'Navigation', items = {
|
|||
|
{title = 'Next', hint = 'playlist or file', value = 'script-binding uosc/next'},
|
|||
|
{title = 'Prev', hint = 'playlist or file', value = 'script-binding uosc/prev'},
|
|||
|
{title = 'Delete file & Next', value = 'script-binding uosc/delete-file-next'},
|
|||
|
{title = 'Delete file & Prev', value = 'script-binding uosc/delete-file-prev'},
|
|||
|
{title = 'Delete file & Quit', value = 'script-binding uosc/delete-file-quit'},
|
|||
|
},},
|
|||
|
{title = 'Utils', items = {
|
|||
|
{title = 'Load subtitles', value = 'script-binding uosc/load-subtitles'},
|
|||
|
{title = 'Aspect ratio', items = {
|
|||
|
{title = 'Default', value = 'set video-aspect-override "-1"'},
|
|||
|
{title = '16:9', value = 'set video-aspect-override "16:9"'},
|
|||
|
{title = '4:3', value = 'set video-aspect-override "4:3"'},
|
|||
|
{title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
|
|||
|
},},
|
|||
|
{title = 'Audio devices', value = 'script-binding uosc/audio-device'},
|
|||
|
{title = 'Screenshot', value = 'async screenshot'},
|
|||
|
{title = 'Show in directory', value = 'script-binding uosc/show-in-directory'},
|
|||
|
{title = 'Open config folder', value = 'script-binding uosc/open-config-directory'},
|
|||
|
},},
|
|||
|
{title = 'Quit', value = 'quit'},
|
|||
|
}
|
|||
|
end
|
|||
|
end)(),
|
|||
|
chapter_ranges = (function()
|
|||
|
---@type table<string, string[]> Alternative patterns.
|
|||
|
local alt_patterns = {}
|
|||
|
if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then
|
|||
|
for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do
|
|||
|
local name_patterns = split(definition, ' *:')
|
|||
|
local name, patterns = name_patterns[1], name_patterns[2]
|
|||
|
if name and patterns then alt_patterns[name] = split(patterns, ',') end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@type table<string, {color: string; opacity: number; patterns?: string[]}>
|
|||
|
local ranges = {}
|
|||
|
if options.chapter_ranges and options.chapter_ranges ~= '' then
|
|||
|
for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
|
|||
|
local name_color = split(definition, ' *:+ *')
|
|||
|
local name, color = name_color[1], name_color[2]
|
|||
|
if name and color
|
|||
|
and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$')
|
|||
|
and (#color == 6 or #color == 8) then
|
|||
|
local range = serialize_rgba(name_color[2])
|
|||
|
range.patterns = alt_patterns[name]
|
|||
|
ranges[name_color[1]] = range
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
return ranges
|
|||
|
end)(),
|
|||
|
}
|
|||
|
-- Adds `{element}_persistency` property with table of flags when the element should be visible (`{paused = true}`)
|
|||
|
for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do
|
|||
|
local option_name = name .. '_persistency'
|
|||
|
local value, flags = options[option_name], {}
|
|||
|
if type(value) == 'string' then
|
|||
|
for _, state in ipairs(split(value, ' *, *')) do flags[state] = true end
|
|||
|
end
|
|||
|
config[option_name] = flags
|
|||
|
end
|
|||
|
|
|||
|
--[[ STATE ]]
|
|||
|
|
|||
|
local display = {width = 1280, height = 720, scale_x = 1, scale_y = 1}
|
|||
|
local cursor = {hidden = true, 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'),
|
|||
|
path = nil, -- current file path or URL
|
|||
|
title = nil,
|
|||
|
time = nil, -- current media playback time
|
|||
|
speed = 1,
|
|||
|
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 = {},
|
|||
|
current_chapter = nil,
|
|||
|
chapter_ranges = {},
|
|||
|
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_video = false,
|
|||
|
is_audio = false, -- true if file is audio only (mp3, etc)
|
|||
|
is_image = false,
|
|||
|
is_stream = false,
|
|||
|
has_audio = false,
|
|||
|
has_sub = false,
|
|||
|
has_chapter = false,
|
|||
|
has_playlist = false,
|
|||
|
shuffle = options.shuffle,
|
|||
|
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,
|
|||
|
uncached_ranges = nil,
|
|||
|
cache = nil,
|
|||
|
render_delay = config.render_delay,
|
|||
|
first_real_mouse_move_received = false,
|
|||
|
playlist_count = 0,
|
|||
|
playlist_pos = 0,
|
|||
|
margin_top = 0,
|
|||
|
margin_bottom = 0,
|
|||
|
hidpi_scale = 1,
|
|||
|
}
|
|||
|
local thumbnail = {width = 0, height = 0, disabled = false}
|
|||
|
|
|||
|
--[[ HELPERS ]]
|
|||
|
|
|||
|
-- Sorting comparator close to (but not exactly) how file explorers sort files
|
|||
|
local file_order_comparator = (function()
|
|||
|
local symbol_order
|
|||
|
local default_order
|
|||
|
|
|||
|
if state.os == 'win' then
|
|||
|
symbol_order = {
|
|||
|
['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7,
|
|||
|
['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14,
|
|||
|
['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20,
|
|||
|
}
|
|||
|
default_order = 21
|
|||
|
else
|
|||
|
symbol_order = {
|
|||
|
['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8,
|
|||
|
['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14,
|
|||
|
['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23,
|
|||
|
}
|
|||
|
default_order = 21
|
|||
|
end
|
|||
|
|
|||
|
---@param a string|number
|
|||
|
---@param b string|number
|
|||
|
---@return boolean
|
|||
|
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.
|
|||
|
---@param from number
|
|||
|
---@param to number|fun():number
|
|||
|
---@param setter fun(value: number)
|
|||
|
---@param factor_or_callback? number|fun()
|
|||
|
---@param callback? fun() Called either on animation end, or when animation is killed.
|
|||
|
function tween(from, to, setter, factor_or_callback, callback)
|
|||
|
local factor = factor_or_callback
|
|||
|
if type(factor_or_callback) == 'function' then callback = factor_or_callback end
|
|||
|
if type(factor) ~= 'number' then factor = 0.3 end
|
|||
|
|
|||
|
local current, done, timeout = from, false, nil
|
|||
|
local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
|
|||
|
local cutoff = math.abs(get_to() - from) * 0.01
|
|||
|
|
|||
|
local function finish()
|
|||
|
if not done then
|
|||
|
done = true
|
|||
|
timeout:kill()
|
|||
|
if callback then callback() end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
local function tick()
|
|||
|
local to = get_to()
|
|||
|
current = current + ((to - current) * factor)
|
|||
|
local is_end = math.abs(to - current) <= cutoff
|
|||
|
setter(is_end and to or current)
|
|||
|
request_render()
|
|||
|
if is_end then finish()
|
|||
|
else timeout:resume() end
|
|||
|
end
|
|||
|
|
|||
|
timeout = mp.add_timeout(state.render_delay, tick)
|
|||
|
tick()
|
|||
|
|
|||
|
return finish
|
|||
|
end
|
|||
|
|
|||
|
---@param point {x: number; y: number}
|
|||
|
---@param rect {ax: number; ay: number; bx: number; by: number}
|
|||
|
function get_point_to_rectangle_proximity(point, rect)
|
|||
|
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
|
|||
|
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
|
|||
|
return math.sqrt(dx * dx + dy * dy);
|
|||
|
end
|
|||
|
|
|||
|
---@param text string|number
|
|||
|
---@param font_size number
|
|||
|
function text_width_estimate(text, font_size) return text_length_width_estimate(text_length(text), font_size) end
|
|||
|
|
|||
|
---@param length number
|
|||
|
---@param font_size number
|
|||
|
function text_length_width_estimate(length, font_size)
|
|||
|
return length * font_size * options.font_height_to_letter_width_ratio
|
|||
|
end
|
|||
|
|
|||
|
---@param text string|number
|
|||
|
function text_length(text)
|
|||
|
if not text or text == '' then return 0 end
|
|||
|
local text_length = 0
|
|||
|
for _, _, length in utf8_iter(tostring(text)) do text_length = text_length + length end
|
|||
|
return text_length
|
|||
|
end
|
|||
|
|
|||
|
function utf8_iter(string)
|
|||
|
local byte_start, byte_count = 1, 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, target_line_length)
|
|||
|
local line_length = 0
|
|||
|
local wrap_at_chars = {' ', ' ', '-', '–'}
|
|||
|
local remove_when_wrap = {' ', ' '}
|
|||
|
local lines = {}
|
|||
|
local line_start = 1
|
|||
|
local before_end = nil
|
|||
|
local before_length = 0
|
|||
|
local before_line_start = 0
|
|||
|
local before_removed_length = 0
|
|||
|
local max_length = 0
|
|||
|
for char_start, count, char_length 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_length = line_length + char_length
|
|||
|
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_length_after_remove = line_length - (remove and char_length or 0)
|
|||
|
if line_length_after_remove < target_line_length then
|
|||
|
before_end = remove and char_start - 1 or char_end
|
|||
|
before_length = line_length_after_remove
|
|||
|
before_line_start = char_end + 1
|
|||
|
before_removed_length = remove and char_length or 0
|
|||
|
else
|
|||
|
if (target_line_length - before_length) <
|
|||
|
(line_length_after_remove - target_line_length) then
|
|||
|
lines[#lines + 1] = text.sub(text, line_start, before_end)
|
|||
|
line_start = before_line_start
|
|||
|
line_length = line_length - before_length - before_removed_length
|
|||
|
if before_length > max_length then max_length = before_length end
|
|||
|
else
|
|||
|
lines[#lines + 1] = text.sub(text, line_start, remove and char_start - 1 or char_end)
|
|||
|
line_start = char_end + 1
|
|||
|
line_length = remove and line_length - char_length or line_length
|
|||
|
if line_length > max_length then max_length = line_length end
|
|||
|
line_length = 0
|
|||
|
end
|
|||
|
before_end = line_start
|
|||
|
before_length = 0
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
if #text >= line_start then
|
|||
|
lines[#lines + 1] = string.sub(text, line_start)
|
|||
|
if line_length > max_length then max_length = line_length end
|
|||
|
end
|
|||
|
return table.concat(lines, '\n'), max_length, #lines
|
|||
|
end
|
|||
|
|
|||
|
-- Escape a string for verbatim display on the OSD
|
|||
|
---@param str string
|
|||
|
function ass_escape(str)
|
|||
|
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
|||
|
-- it isn't followed by a recognized character, so add a zero-width
|
|||
|
-- non-breaking space
|
|||
|
str = str:gsub('\\', '\\\239\187\191')
|
|||
|
str = str:gsub('{', '\\{')
|
|||
|
str = str:gsub('}', '\\}')
|
|||
|
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
|||
|
-- consecutive newlines
|
|||
|
str = str:gsub('\n', '\239\187\191\\N')
|
|||
|
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
|
|||
|
str = str:gsub('\\N ', '\\N\\h')
|
|||
|
str = str:gsub('^ ', '\\h')
|
|||
|
return str
|
|||
|
end
|
|||
|
|
|||
|
---@param seconds number
|
|||
|
---@return string
|
|||
|
function format_time(seconds)
|
|||
|
local human = mp.format_time(seconds)
|
|||
|
if options.time_precision > 0 then
|
|||
|
local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
|
|||
|
human = human .. '.' .. string.sub(formatted, 3)
|
|||
|
end
|
|||
|
return human
|
|||
|
end
|
|||
|
|
|||
|
---@param opacity number 0-1
|
|||
|
function opacity_to_alpha(opacity)
|
|||
|
return 255 - math.ceil(255 * opacity)
|
|||
|
end
|
|||
|
|
|||
|
-- Ensures path is absolute and normalizes slashes to the current platform
|
|||
|
---@param path string
|
|||
|
function normalize_path(path)
|
|||
|
if not path or is_protocol(path) then return path end
|
|||
|
|
|||
|
-- Ensure path is absolute
|
|||
|
if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then
|
|||
|
path = utils.join_path(state.cwd, path)
|
|||
|
end
|
|||
|
|
|||
|
-- Remove trailing slashes
|
|||
|
if #path > 1 then
|
|||
|
path = path:gsub('[\\/]+$', '')
|
|||
|
path = #path == 0 and '/' or path
|
|||
|
end
|
|||
|
|
|||
|
-- Use proper slashes
|
|||
|
if state.os == 'windows' then
|
|||
|
-- Drive letters on windows need trailing backslash
|
|||
|
if path:sub(#path) == ':' then
|
|||
|
path = path .. '\\'
|
|||
|
end
|
|||
|
|
|||
|
return path:gsub('/', '\\')
|
|||
|
else
|
|||
|
return path:gsub('\\', '/')
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Check if path is a protocol, such as `http://...`
|
|||
|
---@param path string
|
|||
|
function is_protocol(path)
|
|||
|
return type(path) == 'string' and path:match('^%a[%a%d-_]+://') ~= nil
|
|||
|
end
|
|||
|
|
|||
|
---@param path string
|
|||
|
function get_extension(path)
|
|||
|
local parts = split(path, '%.')
|
|||
|
return parts and #parts > 1 and parts[#parts] or nil
|
|||
|
end
|
|||
|
|
|||
|
---@return string
|
|||
|
function get_default_directory()
|
|||
|
return mp.command_native({'expand-path', options.default_directory})
|
|||
|
end
|
|||
|
|
|||
|
-- Serializes path into its semantic parts
|
|||
|
---@param path string
|
|||
|
---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
|
|||
|
function serialize_path(path)
|
|||
|
if not path or is_protocol(path) then return end
|
|||
|
|
|||
|
local normal_path = normalize_path(path)
|
|||
|
-- normalize_path() already strips slashes, but leaves trailing backslash
|
|||
|
-- for windows drive letters, but we don't need it here.
|
|||
|
local working_path = normal_path:sub(#normal_path) == '\\' and normal_path:sub(1, #normal_path - 1) or normal_path
|
|||
|
local parts = split(working_path, '[\\/]+')
|
|||
|
local basename = parts and parts[#parts] or working_path
|
|||
|
local dirname = #parts > 1
|
|||
|
and table.concat(itable_slice(parts, 1, #parts - 1), state.os == 'windows' and '\\' or '/')
|
|||
|
or nil
|
|||
|
local dot_split = split(basename, '%.')
|
|||
|
|
|||
|
return {
|
|||
|
path = normal_path,
|
|||
|
is_root = dirname == nil,
|
|||
|
dirname = dirname,
|
|||
|
basename = basename,
|
|||
|
filename = #dot_split > 1 and table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or basename,
|
|||
|
extension = #dot_split > 1 and dot_split[#dot_split] or nil,
|
|||
|
}
|
|||
|
end
|
|||
|
|
|||
|
---@param directory string
|
|||
|
---@param allowed_types? string[]
|
|||
|
---@return nil|string[]
|
|||
|
function get_files_in_directory(directory, allowed_types)
|
|||
|
local files, error = utils.readdir(directory, 'files')
|
|||
|
|
|||
|
if not files then
|
|||
|
msg.error('Retrieving files failed: ' .. (error or ''))
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
-- Filter only requested file types
|
|||
|
if allowed_types then
|
|||
|
files = itable_filter(files, function(file)
|
|||
|
local extension = get_extension(file)
|
|||
|
return extension and itable_index_of(allowed_types, extension:lower())
|
|||
|
end)
|
|||
|
end
|
|||
|
|
|||
|
table.sort(files, file_order_comparator)
|
|||
|
|
|||
|
return files
|
|||
|
end
|
|||
|
|
|||
|
-- Returns full absolute paths of files in the same directory as file_path,
|
|||
|
-- and index of the current file in the table.
|
|||
|
---@param file_path string
|
|||
|
---@param allowed_types? string[]
|
|||
|
function get_adjacent_paths(file_path, allowed_types)
|
|||
|
local current_file = serialize_path(file_path)
|
|||
|
if not current_file then return end
|
|||
|
local files = get_files_in_directory(current_file.dirname, allowed_types)
|
|||
|
if not files then return end
|
|||
|
local current_file_index
|
|||
|
local paths = {}
|
|||
|
for index, file in ipairs(files) do
|
|||
|
paths[#paths + 1] = utils.join_path(current_file.dirname, file)
|
|||
|
if current_file.basename == file then current_file_index = index end
|
|||
|
end
|
|||
|
if not current_file_index then return end
|
|||
|
return paths, current_file_index
|
|||
|
end
|
|||
|
|
|||
|
-- Navigates in a list, using delta or, when `state.shuffle` is enabled,
|
|||
|
-- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
|
|||
|
---@param list table
|
|||
|
---@param current_index number
|
|||
|
---@param delta number
|
|||
|
function decide_navigation_in_list(list, current_index, delta)
|
|||
|
if #list < 2 then return #list, list[#list] end
|
|||
|
|
|||
|
if state.shuffle then
|
|||
|
local new_index = current_index
|
|||
|
while current_index == new_index do new_index = math.random(#list) end
|
|||
|
return new_index, list[new_index]
|
|||
|
end
|
|||
|
|
|||
|
local new_index = current_index + delta
|
|||
|
if mp.get_property_native('loop-playlist') then
|
|||
|
if new_index > #list then new_index = new_index % #list
|
|||
|
elseif new_index < 1 then new_index = #list - new_index end
|
|||
|
elseif new_index < 1 or new_index > #list then
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
return new_index, list[new_index]
|
|||
|
end
|
|||
|
|
|||
|
---@param delta number
|
|||
|
function navigate_directory(delta)
|
|||
|
if not state.path or is_protocol(state.path) then return false end
|
|||
|
local paths, current_index = get_adjacent_paths(state.path, config.media_types)
|
|||
|
if paths and current_index then
|
|||
|
local _, path = decide_navigation_in_list(paths, current_index, delta)
|
|||
|
if path then mp.commandv('loadfile', path) return true end
|
|||
|
end
|
|||
|
return false
|
|||
|
end
|
|||
|
|
|||
|
---@param delta number
|
|||
|
function navigate_playlist(delta)
|
|||
|
local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
|
|||
|
if playlist and #playlist > 1 and pos then
|
|||
|
local index = decide_navigation_in_list(playlist, pos, delta)
|
|||
|
if index then mp.commandv('playlist-play-index', index - 1) return true end
|
|||
|
end
|
|||
|
return false
|
|||
|
end
|
|||
|
|
|||
|
---@param delta number
|
|||
|
function navigate_item(delta)
|
|||
|
if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
|
|||
|
end
|
|||
|
|
|||
|
-- Can't use `os.remove()` as it fails on paths with unicode characters.
|
|||
|
-- Returns `result, error`, result is table of:
|
|||
|
-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
|
|||
|
---@param path string
|
|||
|
function delete_file(path)
|
|||
|
local args = state.os == 'windows' and {'cmd', '/C', 'del', path} or {'rm', path}
|
|||
|
return mp.command_native({
|
|||
|
name = 'subprocess',
|
|||
|
args = args,
|
|||
|
playback_only = false,
|
|||
|
capture_stdout = true,
|
|||
|
capture_stderr = true,
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
function serialize_chapter_ranges(normalized_chapters)
|
|||
|
local ranges = {}
|
|||
|
local simple_ranges = {
|
|||
|
{name = 'openings', patterns = {'^op ', '^op$', ' op$', 'opening$'}, requires_next_chapter = true},
|
|||
|
{name = 'intros', patterns = {'^intro$'}, requires_next_chapter = true},
|
|||
|
{name = 'endings', patterns = {'^ed ', '^ed$', ' ed$', 'ending$'}},
|
|||
|
{name = 'outros', patterns = {'^outro$'}},
|
|||
|
}
|
|||
|
local sponsor_ranges = {}
|
|||
|
|
|||
|
-- Extend with alt patterns
|
|||
|
for _, meta in ipairs(simple_ranges) do
|
|||
|
local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
|
|||
|
if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
|
|||
|
end
|
|||
|
|
|||
|
-- Clone chapters
|
|||
|
local chapters = {}
|
|||
|
for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end
|
|||
|
|
|||
|
for i, chapter in ipairs(chapters) do
|
|||
|
-- Simple ranges
|
|||
|
for _, meta in ipairs(simple_ranges) do
|
|||
|
if config.chapter_ranges[meta.name] then
|
|||
|
local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
|
|||
|
if match then
|
|||
|
local next_chapter = chapters[i + 1]
|
|||
|
if next_chapter or not meta.requires_next_chapter then
|
|||
|
ranges[#ranges + 1] = table_assign({
|
|||
|
start = chapter.time,
|
|||
|
['end'] = next_chapter and next_chapter.time or infinity,
|
|||
|
}, config.chapter_ranges[meta.name])
|
|||
|
chapter.is_range_point = true
|
|||
|
if next_chapter then next_chapter.is_range_point = true end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Sponsor blocks
|
|||
|
if config.chapter_ranges.ads then
|
|||
|
local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
|
|||
|
if id then
|
|||
|
for j = i + 1, #chapters, 1 do
|
|||
|
local end_chapter = chapters[j]
|
|||
|
local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
|
|||
|
if end_match then
|
|||
|
local range = table_assign({
|
|||
|
start_chapter = chapter, end_chapter = end_chapter,
|
|||
|
start = chapter.time, ['end'] = end_chapter.time,
|
|||
|
}, config.chapter_ranges.ads)
|
|||
|
ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
|
|||
|
chapter.is_range_point, end_chapter.is_range_point, end_chapter.is_end_only = true, true, true
|
|||
|
break
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Fix overlapping sponsor block segments
|
|||
|
for index, range in ipairs(sponsor_ranges) do
|
|||
|
local next_range = sponsor_ranges[index + 1]
|
|||
|
if next_range then
|
|||
|
local delta = next_range.start - range['end']
|
|||
|
if delta < 0 then
|
|||
|
local mid_point = range['end'] + delta / 2
|
|||
|
range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
|
|||
|
next_range.start, next_range.start_chapter.time = mid_point, mid_point
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
table.sort(chapters, function(a, b) return a.time < b.time end)
|
|||
|
|
|||
|
return chapters, ranges
|
|||
|
end
|
|||
|
|
|||
|
-- Ensures chapters are in chronological order
|
|||
|
function normalize_chapters(chapters)
|
|||
|
if not chapters then return {} end
|
|||
|
-- Ensure chronological order
|
|||
|
table.sort(chapters, function(a, b) return a.time < b.time end)
|
|||
|
-- Ensure titles
|
|||
|
for index, chapter in ipairs(chapters) do
|
|||
|
chapter.title = chapter.title or ('Chapter ' .. index)
|
|||
|
chapter.lowercase_title = chapter.title:lower()
|
|||
|
end
|
|||
|
return chapters
|
|||
|
end
|
|||
|
|
|||
|
function serialize_chapters(chapters)
|
|||
|
chapters = normalize_chapters(chapters)
|
|||
|
if not chapters then return end
|
|||
|
for index, chapter in ipairs(chapters) do
|
|||
|
chapter.index = index
|
|||
|
chapter.title_wrapped, chapter.title_wrapped_width, chapter.title_wrapped_lines = wrap_text(chapter.title, 25)
|
|||
|
chapter.title_wrapped = ass_escape(chapter.title_wrapped)
|
|||
|
end
|
|||
|
return chapters
|
|||
|
end
|
|||
|
|
|||
|
--[[ ASSDRAW EXTENSIONS ]]
|
|||
|
|
|||
|
local ass_mt = getmetatable(assdraw.ass_new())
|
|||
|
|
|||
|
-- Opacity
|
|||
|
---@param opacity number|number[] Opacity of all elements, or an array of [primary, secondary, border, shadow] opacities.
|
|||
|
---@param fraction? number Optionally adjust the above opacity by this fraction.
|
|||
|
function ass_mt:opacity(opacity, fraction)
|
|||
|
fraction = fraction ~= nil and fraction or 1
|
|||
|
if type(opacity) == 'number' then
|
|||
|
self.text = self.text .. string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction))
|
|||
|
else
|
|||
|
self.text = self.text .. string.format(
|
|||
|
'{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}',
|
|||
|
opacity_to_alpha((opacity[1] or 0) * fraction),
|
|||
|
opacity_to_alpha((opacity[2] or 0) * fraction),
|
|||
|
opacity_to_alpha((opacity[3] or 0) * fraction),
|
|||
|
opacity_to_alpha((opacity[4] or 0) * fraction)
|
|||
|
)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Icon
|
|||
|
---@param x number
|
|||
|
---@param y number
|
|||
|
---@param size number
|
|||
|
---@param name string
|
|||
|
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number}
|
|||
|
function ass_mt:icon(x, y, size, name, opts)
|
|||
|
opts = opts or {}
|
|||
|
opts.size = size
|
|||
|
opts.font = 'MaterialIconsRound-Regular'
|
|||
|
self:txt(x, y, opts.align or 5, name, opts)
|
|||
|
end
|
|||
|
|
|||
|
-- Text
|
|||
|
-- Named `txt` because `ass.text` is a value.
|
|||
|
---@param x number
|
|||
|
---@param y number
|
|||
|
---@param align number
|
|||
|
---@param value string|number
|
|||
|
---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; wrap?: number; opacity?: number; clip?: string}
|
|||
|
function ass_mt:txt(x, y, align, value, opts)
|
|||
|
local border_size = opts.border or 0
|
|||
|
local shadow_size = opts.shadow or 0
|
|||
|
local tags = '\\pos(' .. x .. ',' .. y .. ')\\an' .. align .. '\\blur0'
|
|||
|
-- font
|
|||
|
tags = tags .. '\\fn' .. (opts.font or config.font)
|
|||
|
-- font size
|
|||
|
tags = tags .. '\\fs' .. opts.size
|
|||
|
-- bold
|
|||
|
if opts.bold or options.font_bold then tags = tags .. '\\b1' end
|
|||
|
-- italic
|
|||
|
if opts.italic then tags = tags .. '\\i1' end
|
|||
|
-- wrap
|
|||
|
if opts.wrap then tags = tags .. '\\q' .. opts.wrap end
|
|||
|
-- border
|
|||
|
tags = tags .. '\\bord' .. border_size
|
|||
|
-- shadow
|
|||
|
tags = tags .. '\\shad' .. shadow_size
|
|||
|
-- colors
|
|||
|
tags = tags .. '\\1c&H' .. (opts.color or options.foreground)
|
|||
|
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or options.background) end
|
|||
|
if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or options.background) end
|
|||
|
-- opacity
|
|||
|
if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
|
|||
|
-- clip
|
|||
|
if opts.clip then tags = tags .. opts.clip end
|
|||
|
-- render
|
|||
|
self:new_event()
|
|||
|
self.text = self.text .. '{' .. tags .. '}' .. value
|
|||
|
end
|
|||
|
|
|||
|
-- Tooltip
|
|||
|
---@param element {ax: number; ay: number; bx: number; by: number}
|
|||
|
---@param value string|number
|
|||
|
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; text_length_override?: number; responsive?: boolean}
|
|||
|
function ass_mt:tooltip(element, value, opts)
|
|||
|
opts = opts or {}
|
|||
|
opts.size = opts.size or 16
|
|||
|
opts.border = options.text_border
|
|||
|
opts.border_color = options.background
|
|||
|
local offset = opts.offset or opts.size / 2
|
|||
|
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
|
|||
|
local x = element.ax + (element.bx - element.ax) / 2
|
|||
|
local y = align_top and element.ay - offset or element.by + offset
|
|||
|
local text_width = opts.text_length_override
|
|||
|
and opts.text_length_override * opts.size * options.font_height_to_letter_width_ratio
|
|||
|
or text_width_estimate(value, opts.size)
|
|||
|
local margin = text_width / 2
|
|||
|
self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts)
|
|||
|
end
|
|||
|
|
|||
|
-- Rectangle
|
|||
|
---@param ax number
|
|||
|
---@param ay number
|
|||
|
---@param bx number
|
|||
|
---@param by number
|
|||
|
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string, radius?: number}
|
|||
|
function ass_mt:rect(ax, ay, bx, by, opts)
|
|||
|
opts = opts or {}
|
|||
|
local border_size = opts.border or 0
|
|||
|
local tags = '\\pos(0,0)\\blur0'
|
|||
|
-- border
|
|||
|
tags = tags .. '\\bord' .. border_size
|
|||
|
-- colors
|
|||
|
tags = tags .. '\\1c&H' .. (opts.color or options.foreground)
|
|||
|
if border_size > 0 then
|
|||
|
tags = tags .. '\\3c&H' .. (opts.border_color or options.background)
|
|||
|
end
|
|||
|
-- opacity
|
|||
|
if opts.opacity then
|
|||
|
tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity))
|
|||
|
end
|
|||
|
-- clip
|
|||
|
if opts.clip then
|
|||
|
tags = tags .. opts.clip
|
|||
|
end
|
|||
|
-- draw
|
|||
|
self:new_event()
|
|||
|
self.text = self.text .. '{' .. tags .. '}'
|
|||
|
self:draw_start()
|
|||
|
if opts.radius then
|
|||
|
self:round_rect_cw(ax, ay, bx, by, opts.radius)
|
|||
|
else
|
|||
|
self:rect_cw(ax, ay, bx, by)
|
|||
|
end
|
|||
|
self:draw_stop()
|
|||
|
end
|
|||
|
|
|||
|
-- Circle
|
|||
|
---@param x number
|
|||
|
---@param y number
|
|||
|
---@param radius number
|
|||
|
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string}
|
|||
|
function ass_mt:circle(x, y, radius, opts)
|
|||
|
opts = opts or {}
|
|||
|
opts.radius = radius
|
|||
|
self:rect(x - radius, y - radius, x + radius, y + radius, opts)
|
|||
|
end
|
|||
|
|
|||
|
-- Texture
|
|||
|
---@param ax number
|
|||
|
---@param ay number
|
|||
|
---@param bx number
|
|||
|
---@param by number
|
|||
|
---@param char string Texture font character.
|
|||
|
---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number}
|
|||
|
function ass_mt:texture(ax, ay, bx, by, char, opts)
|
|||
|
opts = opts or {}
|
|||
|
local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay
|
|||
|
local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')')
|
|||
|
local tile_size, opacity = opts.size or 100, opts.opacity or 0.2
|
|||
|
local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size
|
|||
|
local width, height = bx - x, by - y
|
|||
|
local line = string.rep(char, math.ceil((width / tile_size)))
|
|||
|
local lines = ''
|
|||
|
for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end
|
|||
|
self:txt(
|
|||
|
x, y, 7, lines,
|
|||
|
{font = 'uosc_textures', size = tile_size, color = opts.color, opacity = opacity, clip = clip})
|
|||
|
end
|
|||
|
|
|||
|
--[[ ELEMENTS COLLECTION ]]
|
|||
|
|
|||
|
local Elements = {itable = {}}
|
|||
|
|
|||
|
---@param element Element
|
|||
|
function Elements:add(element)
|
|||
|
if not element.id then
|
|||
|
msg.error('attempt to add element without "id" property')
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
if self:has(element.id) then Elements:remove(element.id) end
|
|||
|
|
|||
|
self.itable[#self.itable + 1] = element
|
|||
|
self[element.id] = element
|
|||
|
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Elements:remove(idOrElement)
|
|||
|
if not idOrElement then return end
|
|||
|
local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement
|
|||
|
local element = Elements[id]
|
|||
|
if element then
|
|||
|
if not element.destroyed then element:destroy() end
|
|||
|
element.enabled = false
|
|||
|
self.itable = itable_remove(self.itable, self[id])
|
|||
|
self[id] = nil
|
|||
|
request_render()
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Elements:update_proximities()
|
|||
|
local capture_mbtn_left = false
|
|||
|
local capture_wheel = false
|
|||
|
local menu_only = Elements.menu ~= nil
|
|||
|
local mouse_leave_elements = {}
|
|||
|
local mouse_enter_elements = {}
|
|||
|
|
|||
|
-- Calculates proximities and opacities for defined elements
|
|||
|
for _, element in self:ipairs() do
|
|||
|
if element.enabled then
|
|||
|
local previous_proximity_raw = element.proximity_raw
|
|||
|
|
|||
|
-- If menu is open, all other elements have to be disabled
|
|||
|
if menu_only then
|
|||
|
if element.ignores_menu then
|
|||
|
capture_mbtn_left = true
|
|||
|
capture_wheel = true
|
|||
|
element:update_proximity()
|
|||
|
else
|
|||
|
element.proximity_raw = infinity
|
|||
|
element.proximity = 0
|
|||
|
end
|
|||
|
else
|
|||
|
element:update_proximity()
|
|||
|
end
|
|||
|
|
|||
|
-- Element has global forced key listeners
|
|||
|
if element.on_global_mbtn_left_down then capture_mbtn_left = true end
|
|||
|
if element.on_global_wheel_up or element.on_global_wheel_down then capture_wheel = true end
|
|||
|
|
|||
|
if element.proximity_raw == 0 then
|
|||
|
-- Element has local forced key listeners
|
|||
|
if element.on_mbtn_left_down then capture_mbtn_left = true end
|
|||
|
if element.on_wheel_up or element.on_wheel_up then capture_wheel = true end
|
|||
|
|
|||
|
-- Mouse entered element area
|
|||
|
if previous_proximity_raw ~= 0 then
|
|||
|
mouse_enter_elements[#mouse_enter_elements + 1] = element
|
|||
|
end
|
|||
|
else
|
|||
|
-- Mouse left element area
|
|||
|
if previous_proximity_raw == 0 then
|
|||
|
mouse_leave_elements[#mouse_leave_elements + 1] = element
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Enable key group captures requested by elements
|
|||
|
mp[capture_mbtn_left and 'enable_key_bindings' or 'disable_key_bindings']('mbtn_left')
|
|||
|
mp[capture_wheel and 'enable_key_bindings' or 'disable_key_bindings']('wheel')
|
|||
|
|
|||
|
-- Trigger `mouse_leave` and `mouse_enter` events
|
|||
|
for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end
|
|||
|
for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end
|
|||
|
end
|
|||
|
|
|||
|
-- Toggles passed elements' min visibilities between 0 and 1.
|
|||
|
---@param ids string[] IDs of elements to peek.
|
|||
|
function Elements:toggle(ids)
|
|||
|
local elements = itable_filter(self.itable, function(element) return itable_index_of(ids, element.id) ~= nil end)
|
|||
|
local all_visible = itable_find(elements, function(element) return element.min_visibility ~= 1 end) == nil
|
|||
|
local to = all_visible and 0 or 1
|
|||
|
for _, element in ipairs(elements) do element:tween_property('min_visibility', element.min_visibility, to) end
|
|||
|
end
|
|||
|
|
|||
|
---@param name string Event name.
|
|||
|
function Elements:trigger(name, ...)
|
|||
|
for _, element in self:ipairs() do element:trigger(name, ...) end
|
|||
|
end
|
|||
|
|
|||
|
-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity.
|
|||
|
-- Disabled elements don't receive these events.
|
|||
|
---@param name string Event name.
|
|||
|
function Elements:proximity_trigger(name, ...)
|
|||
|
for _, element in self:ipairs() do
|
|||
|
if element.enabled then
|
|||
|
if element.proximity_raw == 0 then element:trigger(name, ...) end
|
|||
|
element:trigger('global_' .. name, ...)
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Elements:has(id) return self[id] ~= nil end
|
|||
|
function Elements:ipairs() return ipairs(self.itable) end
|
|||
|
|
|||
|
---@param name string Event name.
|
|||
|
function Elements:create_proximity_dispatcher(name)
|
|||
|
return function(...) self:proximity_trigger(name, ...) end
|
|||
|
end
|
|||
|
|
|||
|
mp.set_key_bindings({
|
|||
|
{
|
|||
|
'mbtn_left',
|
|||
|
Elements:create_proximity_dispatcher('mbtn_left_up'),
|
|||
|
Elements:create_proximity_dispatcher('mbtn_left_down'),
|
|||
|
},
|
|||
|
{'mbtn_left_dbl', 'ignore'},
|
|||
|
}, 'mbtn_left', 'force')
|
|||
|
|
|||
|
mp.set_key_bindings({
|
|||
|
{'wheel_up', Elements:create_proximity_dispatcher('wheel_up')},
|
|||
|
{'wheel_down', Elements:create_proximity_dispatcher('wheel_down')},
|
|||
|
}, 'wheel', 'force')
|
|||
|
|
|||
|
--[[ STATE UPDATES ]]
|
|||
|
|
|||
|
function update_display_dimensions()
|
|||
|
local scale = (state.hidpi_scale or 1) * options.ui_scale
|
|||
|
local real_width, real_height = mp.get_osd_size()
|
|||
|
local scaled_width, scaled_height = round(real_width / scale), round(real_height / scale)
|
|||
|
display.width, display.height = scaled_width, scaled_height
|
|||
|
display.scale_x, display.scale_y = real_width / scaled_width, real_height / scaled_height
|
|||
|
|
|||
|
-- Tell elements about this
|
|||
|
Elements:trigger('display')
|
|||
|
|
|||
|
-- Some elements probably changed their rectangles as a reaction to `display`
|
|||
|
Elements:update_proximities()
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function update_fullormaxed()
|
|||
|
state.fullormaxed = state.fullscreen or state.maximized
|
|||
|
update_display_dimensions()
|
|||
|
Elements:trigger('prop_fullormaxed', state.fullormaxed)
|
|||
|
end
|
|||
|
|
|||
|
function update_human_times()
|
|||
|
if state.time then
|
|||
|
state.time_human = format_time(state.time)
|
|||
|
if state.duration then
|
|||
|
local speed = state.speed or 1
|
|||
|
state.duration_or_remaining_time_human = format_time(
|
|||
|
options.total_time and state.duration or ((state.time - state.duration) / speed)
|
|||
|
)
|
|||
|
else
|
|||
|
state.duration_or_remaining_time_human = nil
|
|||
|
end
|
|||
|
else
|
|||
|
state.time_human = nil
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Notifies other scripts such as console about where the unoccupied parts of the screen are.
|
|||
|
function update_margins()
|
|||
|
-- margins are normalized to window size
|
|||
|
local timeline, top_bar, controls = Elements.timeline, Elements.top_bar, Elements.controls
|
|||
|
local bottom_y = controls and controls.enabled and controls.ay or timeline.ay
|
|||
|
local top, bottom = 0, (display.height - bottom_y) / display.height
|
|||
|
|
|||
|
if top_bar.enabled and top_bar:get_visibility() > 0 then
|
|||
|
top = (top_bar.size or 0) / display.height
|
|||
|
end
|
|||
|
|
|||
|
if top == state.margin_top and bottom == state.margin_bottom then return end
|
|||
|
|
|||
|
state.margin_top = top
|
|||
|
state.margin_bottom = bottom
|
|||
|
|
|||
|
utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom))
|
|||
|
end
|
|||
|
|
|||
|
--[[ 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
|
|||
|
if element.enabled then
|
|||
|
local result = element:maybe('render')
|
|||
|
if result then
|
|||
|
ass:new_event()
|
|||
|
ass:merge(result)
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- submit
|
|||
|
if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
osd.res_x = display.width
|
|||
|
osd.res_y = display.height
|
|||
|
osd.data = ass.text
|
|||
|
osd.z = 2000
|
|||
|
osd:update()
|
|||
|
|
|||
|
update_margins()
|
|||
|
end
|
|||
|
|
|||
|
--[[ CLASSES ]]
|
|||
|
|
|||
|
---@class Class
|
|||
|
local Class = {}
|
|||
|
function Class:new(...)
|
|||
|
local object = setmetatable({}, {__index = self})
|
|||
|
object:init(...)
|
|||
|
return object
|
|||
|
end
|
|||
|
function Class:init() end
|
|||
|
function Class:destroy() end
|
|||
|
|
|||
|
function class(parent) return setmetatable({}, {__index = parent or Class}) end
|
|||
|
|
|||
|
--[[ ELEMENT ]]
|
|||
|
|
|||
|
---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;}
|
|||
|
|
|||
|
-- Base class all elements inherit from.
|
|||
|
---@class Element : Class
|
|||
|
local Element = class()
|
|||
|
|
|||
|
---@param id string
|
|||
|
---@param props? ElementProps
|
|||
|
function Element:init(id, props)
|
|||
|
self.id = id
|
|||
|
-- `false` means element won't be rendered, or receive events
|
|||
|
self.enabled = true
|
|||
|
-- Element coordinates
|
|||
|
self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0
|
|||
|
-- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range.
|
|||
|
self.proximity = 0
|
|||
|
-- Raw proximity in pixels.
|
|||
|
self.proximity_raw = infinity
|
|||
|
---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility.
|
|||
|
self.min_visibility = 0
|
|||
|
---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations
|
|||
|
self.forced_visibility = nil
|
|||
|
---@type boolean Render this element even when menu is open.
|
|||
|
self.ignores_menu = false
|
|||
|
---@type nil|string ID of an element from which this one should inherit visibility.
|
|||
|
self.anchor_id = nil
|
|||
|
|
|||
|
if props then table_assign(self, props) end
|
|||
|
|
|||
|
-- Flash timer
|
|||
|
self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
|
|||
|
local getTo = function() return self.proximity end
|
|||
|
self:tween_property('forced_visibility', 1, getTo, function()
|
|||
|
self.forced_visibility = nil
|
|||
|
end)
|
|||
|
end)
|
|||
|
self._flash_out_timer:kill()
|
|||
|
|
|||
|
Elements:add(self)
|
|||
|
end
|
|||
|
|
|||
|
function Element:destroy()
|
|||
|
self.destroyed = true
|
|||
|
Elements:remove(self)
|
|||
|
end
|
|||
|
|
|||
|
---@param ax number
|
|||
|
---@param ay number
|
|||
|
---@param bx number
|
|||
|
---@param by number
|
|||
|
function Element:set_coordinates(ax, ay, bx, by)
|
|||
|
self.ax, self.ay, self.bx, self.by = ax, ay, bx, by
|
|||
|
Elements:update_proximities()
|
|||
|
self:maybe('on_coordinates')
|
|||
|
end
|
|||
|
|
|||
|
function Element:update_proximity()
|
|||
|
if cursor.hidden then
|
|||
|
self.proximity_raw = infinity
|
|||
|
self.proximity = 0
|
|||
|
else
|
|||
|
local range = options.proximity_out - options.proximity_in
|
|||
|
self.proximity_raw = get_point_to_rectangle_proximity(cursor, self)
|
|||
|
self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Decide elements visibility based on proximity and various other factors
|
|||
|
function Element:get_visibility()
|
|||
|
-- Hide when menu is open, unless this is a menu
|
|||
|
---@diagnostic disable-next-line: undefined-global
|
|||
|
if not self.ignores_menu and Menu and Menu:is_open() then return 0 end
|
|||
|
|
|||
|
-- Persistency
|
|||
|
local persist = config[self.id .. '_persistency'];
|
|||
|
if persist and (
|
|||
|
(persist.audio and state.is_audio)
|
|||
|
or (persist.paused and state.pause)
|
|||
|
or (persist.video and state.is_video)
|
|||
|
or (persist.image and state.is_image)
|
|||
|
) then return 1 end
|
|||
|
|
|||
|
-- Forced visibility
|
|||
|
if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end
|
|||
|
|
|||
|
-- Anchor inheritance
|
|||
|
-- If anchor returns -1, it means all attached elements should force hide.
|
|||
|
local anchor = self.anchor_id and Elements[self.anchor_id]
|
|||
|
local anchor_visibility = anchor and anchor:get_visibility() or 0
|
|||
|
|
|||
|
return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility)
|
|||
|
end
|
|||
|
|
|||
|
-- Call method if it exists
|
|||
|
function Element:maybe(name, ...)
|
|||
|
if self[name] then return self[name](self, ...) end
|
|||
|
end
|
|||
|
|
|||
|
-- Attach a tweening animation to this element
|
|||
|
---@param from number
|
|||
|
---@param to number|fun():number
|
|||
|
---@param setter fun(value: number)
|
|||
|
---@param factor_or_callback? number|fun()
|
|||
|
---@param callback? fun() Called either on animation end, or when animation is killed.
|
|||
|
function Element:tween(from, to, setter, factor_or_callback, callback)
|
|||
|
self:tween_stop()
|
|||
|
self._kill_tween = self.enabled and tween(
|
|||
|
from, to, setter, factor_or_callback,
|
|||
|
function()
|
|||
|
self._kill_tween = nil
|
|||
|
if callback then callback() end
|
|||
|
end
|
|||
|
)
|
|||
|
end
|
|||
|
|
|||
|
function Element:is_tweening() return self and self._kill_tween end
|
|||
|
function Element:tween_stop() self:maybe('_kill_tween') end
|
|||
|
|
|||
|
-- Animate an element property between 2 values.
|
|||
|
---@param prop string
|
|||
|
---@param from number
|
|||
|
---@param to number|fun():number
|
|||
|
---@param factor_or_callback? number|fun()
|
|||
|
---@param callback? fun() Called either on animation end, or when animation is killed.
|
|||
|
function Element:tween_property(prop, from, to, factor_or_callback, callback)
|
|||
|
self:tween(from, to, function(value) self[prop] = value end, factor_or_callback, callback)
|
|||
|
end
|
|||
|
|
|||
|
---@param name string
|
|||
|
function Element:trigger(name, ...)
|
|||
|
self:maybe('on_' .. name, ...)
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
-- Briefly flashes the element for `options.flash_duration` milliseconds.
|
|||
|
-- Useful to visualize changes of volume and timeline when changed via hotkeys.
|
|||
|
function Element:flash()
|
|||
|
if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
|
|||
|
self:tween_stop()
|
|||
|
self.forced_visibility = 1
|
|||
|
self._flash_out_timer:kill()
|
|||
|
self._flash_out_timer:resume()
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
--[[ MENU ]]
|
|||
|
--[[
|
|||
|
Usage:
|
|||
|
```
|
|||
|
local data = {
|
|||
|
type = 'foo',
|
|||
|
title = 'Foo',
|
|||
|
items = {
|
|||
|
{title = 'Foo title', hint = 'Ctrl+F', value = 'foo'},
|
|||
|
{title = 'Submenu', items = {...}}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
function open_item(value)
|
|||
|
-- do something with value
|
|||
|
end
|
|||
|
|
|||
|
local menu = Menu:open(items, open_item)
|
|||
|
menu.update(new_data)
|
|||
|
menu.update_items(new_items)
|
|||
|
menu.close()
|
|||
|
```
|
|||
|
]]
|
|||
|
|
|||
|
-- Menu data structure accepted by `Menu:open(menu)`.
|
|||
|
---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected_index?: integer;}
|
|||
|
---@alias MenuDataItem MenuDataValue|MenuData
|
|||
|
---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; keep_open?: boolean; separator?: boolean;}
|
|||
|
---@alias MenuOptions {blurred?: boolean; on_open?: fun(), on_close?: fun()}
|
|||
|
|
|||
|
-- Internal data structure created from `Menu`.
|
|||
|
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_length: number; title_width: number; hint_length: number; hint_width: number; max_width: number; is_root?: boolean;}
|
|||
|
---@alias MenuStackItem MenuStackValue|MenuStack
|
|||
|
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_length: number; title_width: number; hint_length: number; hint_width: number}
|
|||
|
|
|||
|
---@class Menu : Element
|
|||
|
local Menu = class(Element)
|
|||
|
|
|||
|
---@param data MenuData
|
|||
|
---@param callback fun(value: any)
|
|||
|
---@param opts? MenuOptions
|
|||
|
function Menu:open(data, callback, opts)
|
|||
|
local open_menu = self:is_open()
|
|||
|
if open_menu then
|
|||
|
open_menu.is_being_replaced = true
|
|||
|
open_menu:close(true)
|
|||
|
end
|
|||
|
return Menu:new(data, callback, opts)
|
|||
|
end
|
|||
|
|
|||
|
---@param menu_type? string
|
|||
|
---@return Menu|nil
|
|||
|
function Menu:is_open(menu_type)
|
|||
|
return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil
|
|||
|
end
|
|||
|
|
|||
|
---@param immediate? boolean Close immediately without fadeout animation.
|
|||
|
---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed.
|
|||
|
---@overload fun(callback: fun())
|
|||
|
function Menu:close(immediate, callback)
|
|||
|
if type(immediate) ~= 'boolean' then callback = immediate end
|
|||
|
|
|||
|
local menu = self == Menu and Elements.menu or self
|
|||
|
|
|||
|
if menu and not menu.destroyed then
|
|||
|
if menu.is_closing then
|
|||
|
menu:tween_stop()
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
local function close()
|
|||
|
Elements:remove('menu')
|
|||
|
menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {}
|
|||
|
menu:disable_key_bindings()
|
|||
|
Elements:update_proximities()
|
|||
|
if callback then callback() end
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
menu.is_closing = true
|
|||
|
|
|||
|
if immediate then close()
|
|||
|
else menu:fadeout(close) end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param data MenuData
|
|||
|
---@param callback fun(value: any)
|
|||
|
---@param opts? MenuOptions
|
|||
|
---@return Menu
|
|||
|
function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end
|
|||
|
---@param data MenuData
|
|||
|
---@param callback fun(value: any)
|
|||
|
---@param opts? MenuOptions
|
|||
|
function Menu:init(data, callback, opts)
|
|||
|
Element.init(self, 'menu', {ignores_menu = true})
|
|||
|
|
|||
|
-----@type fun()
|
|||
|
self.callback = callback
|
|||
|
self.opts = opts or {}
|
|||
|
self.offset_x = 0 -- Used for submenu transition animation.
|
|||
|
self.item_height = nil
|
|||
|
self.item_spacing = 1
|
|||
|
self.item_padding = nil
|
|||
|
self.font_size = nil
|
|||
|
self.font_size_hint = nil
|
|||
|
self.scroll_step = nil -- Item height + item spacing.
|
|||
|
self.scroll_height = nil -- Items + spacings - container height.
|
|||
|
self.opacity = 0 -- Used to fade in/out.
|
|||
|
self.type = data.type
|
|||
|
---@type MenuStack Root MenuStack.
|
|||
|
self.root = nil
|
|||
|
---@type MenuStack Current MenuStack.
|
|||
|
self.current = nil
|
|||
|
---@type MenuStack[] All menus in a flat array.
|
|||
|
self.all = nil
|
|||
|
---@type table<string, MenuStack> Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
|
|||
|
self.by_id = {}
|
|||
|
self.key_bindings = {}
|
|||
|
self.is_being_replaced = false
|
|||
|
self.is_closing = false
|
|||
|
|
|||
|
self:update(data)
|
|||
|
|
|||
|
if self.opts.blurred then
|
|||
|
if self.current then self.current.selected_index = nil end
|
|||
|
else
|
|||
|
for _, menu in ipairs(self.all) do
|
|||
|
self:scroll_to_index(menu.selected_index, menu)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
self:tween_property('opacity', 0, 1)
|
|||
|
self:enable_key_bindings()
|
|||
|
Elements.curtain:fadein()
|
|||
|
if self.opts.on_open then self.opts.on_open() end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:destroy()
|
|||
|
Element.destroy(self)
|
|||
|
self:disable_key_bindings()
|
|||
|
if not self.is_being_replaced then Elements.curtain:fadeout() end
|
|||
|
if self.opts.on_close then self.opts.on_close() end
|
|||
|
end
|
|||
|
|
|||
|
---@param data MenuData
|
|||
|
function Menu:update(data)
|
|||
|
self.type = data.type
|
|||
|
|
|||
|
local new_root = {is_root = true, title_length = text_length(data.title), hint_length = text_length(data.hint)}
|
|||
|
local new_all = {}
|
|||
|
local new_by_id = {}
|
|||
|
local menus_to_serialize = {{new_root, data}}
|
|||
|
local old_current_id = self.current and self.current.id
|
|||
|
|
|||
|
table_assign(new_root, data, {'title', 'hint', 'keep_open'})
|
|||
|
|
|||
|
local i = 0
|
|||
|
while i < #menus_to_serialize do
|
|||
|
i = i + 1
|
|||
|
local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2]
|
|||
|
local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id
|
|||
|
if not menu.is_root then
|
|||
|
menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
|
|||
|
end
|
|||
|
menu.icon = 'chevron_right'
|
|||
|
|
|||
|
-- Update items
|
|||
|
local first_active_index = nil
|
|||
|
menu.items = {}
|
|||
|
|
|||
|
for i, item_data in ipairs(menu_data.items or {}) do
|
|||
|
if item_data.active and not first_active_index then first_active_index = i end
|
|||
|
|
|||
|
local item = {}
|
|||
|
table_assign(item, item_data, {
|
|||
|
'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator',
|
|||
|
})
|
|||
|
if item.keep_open == nil then item.keep_open = menu.keep_open end
|
|||
|
item.title_length = text_length(item.title)
|
|||
|
item.hint_length = text_length(item.hint)
|
|||
|
|
|||
|
-- Submenu
|
|||
|
if item_data.items then
|
|||
|
item.parent_menu = menu
|
|||
|
menus_to_serialize[#menus_to_serialize + 1] = {item, item_data}
|
|||
|
end
|
|||
|
|
|||
|
menu.items[i] = item
|
|||
|
end
|
|||
|
|
|||
|
if menu.is_root then
|
|||
|
menu.selected_index = menu_data.selected_index or first_active_index or (#menu.items > 0 and 1 or nil)
|
|||
|
end
|
|||
|
|
|||
|
-- Retain old state
|
|||
|
local old_menu = self.by_id[menu.is_root and '__root__' or menu.id]
|
|||
|
if old_menu then table_assign(menu, old_menu, {'selected_index', 'scroll_y'}) end
|
|||
|
|
|||
|
new_all[#new_all + 1] = menu
|
|||
|
new_by_id[menu.is_root and '__root__' or menu.id] = menu
|
|||
|
end
|
|||
|
|
|||
|
self.root, self.all, self.by_id = new_root, new_all, new_by_id
|
|||
|
self.current = self.by_id[old_current_id] or self.root
|
|||
|
local current_selected_index = self.current.selected_index
|
|||
|
|
|||
|
self:update_content_dimensions()
|
|||
|
-- `update_content_dimensions()` triggers `select_item_below_cursor()`
|
|||
|
-- so we need to remember and re-apply `selected_index`.
|
|||
|
self.current.selected_index = current_selected_index
|
|||
|
self:reset_navigation()
|
|||
|
end
|
|||
|
|
|||
|
---@param items MenuDataItem[]
|
|||
|
function Menu:update_items(items)
|
|||
|
local data = table_shallow_copy(self.root)
|
|||
|
data.items = items
|
|||
|
self:update(data)
|
|||
|
end
|
|||
|
|
|||
|
function Menu:update_content_dimensions()
|
|||
|
self.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height
|
|||
|
self.font_size = round(self.item_height * 0.48 * options.font_scale)
|
|||
|
self.font_size_hint = self.font_size - 1
|
|||
|
self.item_padding = round((self.item_height - self.font_size) * 0.6)
|
|||
|
self.scroll_step = self.item_height + self.item_spacing
|
|||
|
|
|||
|
for _, menu in ipairs(self.all) do
|
|||
|
-- Estimate width of a widest item
|
|||
|
local max_width = 0
|
|||
|
for _, item in ipairs(menu.items) do
|
|||
|
local spacings_in_item = 2 + (item.hint and 1 or 0) + (item.icon and 1 or 0)
|
|||
|
local icon_width = item.icon and self.font_size or 0
|
|||
|
item.title_width = text_length_width_estimate(item.title_length, self.font_size)
|
|||
|
item.hint_width = text_length_width_estimate(item.hint_length, self.font_size_hint)
|
|||
|
local estimated_width = item.title_width + item.hint_width + icon_width
|
|||
|
+ (self.item_padding * spacings_in_item)
|
|||
|
if estimated_width > max_width then max_width = estimated_width end
|
|||
|
end
|
|||
|
|
|||
|
-- Also check menu title
|
|||
|
local menu_title_width = text_length_width_estimate(menu.title_length, self.font_size)
|
|||
|
if menu_title_width > max_width then max_width = menu_title_width end
|
|||
|
|
|||
|
menu.max_width = max_width
|
|||
|
end
|
|||
|
|
|||
|
self:update_dimensions()
|
|||
|
end
|
|||
|
|
|||
|
function Menu:update_dimensions()
|
|||
|
-- Coordinates and sizes are of the scrollable area to make
|
|||
|
-- consuming values in rendering and collisions easier. Title drawn above this, so
|
|||
|
-- we need to account for that in max_height and ay position.
|
|||
|
local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width
|
|||
|
|
|||
|
for _, menu in ipairs(self.all) do
|
|||
|
menu.width = round(clamp(min_width, menu.max_width, display.width * 0.9))
|
|||
|
local title_height = (menu.is_root and menu.title) and self.scroll_step or 0
|
|||
|
local max_height = round((display.height - title_height) * 0.9)
|
|||
|
local content_height = self.scroll_step * #menu.items
|
|||
|
menu.height = math.min(content_height - self.item_spacing, max_height)
|
|||
|
menu.top = round(math.max((display.height - menu.height) / 2, title_height * 1.5))
|
|||
|
menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
|
|||
|
self:scroll_to(menu.scroll_y, menu) -- re-applies scroll limits
|
|||
|
end
|
|||
|
|
|||
|
local ax = round((display.width - self.current.width) / 2) + self.offset_x
|
|||
|
self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
|
|||
|
end
|
|||
|
|
|||
|
function Menu:reset_navigation()
|
|||
|
local menu = self.current
|
|||
|
|
|||
|
-- Reset indexes and scroll
|
|||
|
self:select_index(menu.selected_index or (menu.items and #menu.items > 0 and 1 or nil))
|
|||
|
self:scroll_to(menu.scroll_y)
|
|||
|
|
|||
|
-- Walk up the parent menu chain and activate items that lead to current menu
|
|||
|
local parent = menu.parent_menu
|
|||
|
while parent do
|
|||
|
parent.selected_index = itable_index_of(parent.items, menu)
|
|||
|
menu, parent = parent, parent.parent_menu
|
|||
|
end
|
|||
|
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Menu:set_offset_x(offset)
|
|||
|
local delta = offset - self.offset_x
|
|||
|
self.offset_x = offset
|
|||
|
self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by)
|
|||
|
end
|
|||
|
|
|||
|
function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
|
|||
|
|
|||
|
function Menu:get_item_index_below_cursor()
|
|||
|
local menu = self.current
|
|||
|
if #menu.items < 1 or self.proximity_raw > 0 then return nil end
|
|||
|
return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items))
|
|||
|
end
|
|||
|
|
|||
|
function Menu:get_first_active_index(menu)
|
|||
|
menu = menu or self.current
|
|||
|
for index, item in ipairs(self.current.items) do
|
|||
|
if item.active then return index end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param pos? number
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:scroll_to(pos, menu)
|
|||
|
menu = menu or self.current
|
|||
|
menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
---@param index? integer
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:scroll_to_index(index, menu)
|
|||
|
menu = menu or self.current
|
|||
|
if (index and index >= 1 and index <= #menu.items) then
|
|||
|
self:scroll_to(round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2)), menu)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param index? integer
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:select_index(index, menu)
|
|||
|
menu = menu or self.current
|
|||
|
menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
---@param value? any
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:select_value(value, menu)
|
|||
|
menu = menu or self.current
|
|||
|
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
|||
|
self:select_index(index, 5)
|
|||
|
end
|
|||
|
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:deactivate_items(menu)
|
|||
|
menu = menu or self.current
|
|||
|
for _, item in ipairs(menu.items) do item.active = false end
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
---@param index? integer
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:activate_index(index, menu)
|
|||
|
menu = menu or self.current
|
|||
|
if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
---@param index? integer
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:activate_unique_index(index, menu)
|
|||
|
self:deactivate_items(menu)
|
|||
|
self:activate_index(index, menu)
|
|||
|
end
|
|||
|
|
|||
|
---@param value? any
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:activate_value(value, menu)
|
|||
|
menu = menu or self.current
|
|||
|
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
|||
|
self:activate_index(index, menu)
|
|||
|
end
|
|||
|
|
|||
|
---@param value? any
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:activate_unique_value(value, menu)
|
|||
|
menu = menu or self.current
|
|||
|
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
|||
|
self:activate_unique_index(index, menu)
|
|||
|
end
|
|||
|
|
|||
|
---@param id string
|
|||
|
function Menu:activate_submenu(id)
|
|||
|
local submenu = self.by_id[id]
|
|||
|
if submenu then
|
|||
|
self.current = submenu
|
|||
|
request_render()
|
|||
|
else
|
|||
|
msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id))
|
|||
|
end
|
|||
|
self:reset_navigation()
|
|||
|
end
|
|||
|
|
|||
|
---@param index? integer
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:delete_index(index, menu)
|
|||
|
menu = menu or self.current
|
|||
|
if (index and index >= 1 and index <= #menu.items) then
|
|||
|
table.remove(menu.items, index)
|
|||
|
self:update_content_dimensions()
|
|||
|
self:scroll_to_index(menu.selected_index, menu)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
---@param value? any
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:delete_value(value, menu)
|
|||
|
menu = menu or self.current
|
|||
|
local index = itable_find(menu.items, function(_, item) return item.value == value end)
|
|||
|
self:delete_index(index)
|
|||
|
end
|
|||
|
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:prev(menu)
|
|||
|
menu = menu or self.current
|
|||
|
menu.selected_index = math.max(menu.selected_index and menu.selected_index - 1 or #menu.items, 1)
|
|||
|
self:scroll_to_index(menu.selected_index, menu)
|
|||
|
end
|
|||
|
|
|||
|
---@param menu? MenuStack
|
|||
|
function Menu:next(menu)
|
|||
|
menu = menu or self.current
|
|||
|
menu.selected_index = math.min(menu.selected_index and menu.selected_index + 1 or 1, #menu.items)
|
|||
|
self:scroll_to_index(menu.selected_index, menu)
|
|||
|
end
|
|||
|
|
|||
|
function Menu:back()
|
|||
|
local menu = self.current
|
|||
|
local parent = menu.parent_menu
|
|||
|
|
|||
|
if not parent then return self:close() end
|
|||
|
|
|||
|
menu.selected_index = nil
|
|||
|
self.current = parent
|
|||
|
self:update_dimensions()
|
|||
|
self:tween(self.offset_x - menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
|
|||
|
self.opacity = 1 -- in case tween above canceled fade in animation
|
|||
|
end
|
|||
|
|
|||
|
---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean}
|
|||
|
function Menu:open_selected_item(opts)
|
|||
|
opts = opts or {}
|
|||
|
local menu = self.current
|
|||
|
if menu.selected_index then
|
|||
|
local item = menu.items[menu.selected_index]
|
|||
|
-- Is submenu
|
|||
|
if item.items then
|
|||
|
self.current = item
|
|||
|
if opts.preselect_submenu_item then
|
|||
|
item.selected_index = #item.items > 0 and 1 or nil
|
|||
|
end
|
|||
|
self:update_dimensions()
|
|||
|
self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
|
|||
|
self.opacity = 1 -- in case tween above canceled fade in animation
|
|||
|
else
|
|||
|
self.callback(item.value)
|
|||
|
if not item.keep_open and not opts.keep_open then self:close() end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
|
|||
|
function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end
|
|||
|
function Menu:select_item_below_cursor() self.current.selected_index = self:get_item_index_below_cursor() end
|
|||
|
|
|||
|
function Menu:on_display() self:update_dimensions() end
|
|||
|
function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
|
|||
|
|
|||
|
function Menu:on_global_mbtn_left_down()
|
|||
|
if self.proximity_raw == 0 then
|
|||
|
self:select_item_below_cursor()
|
|||
|
self:open_selected_item({preselect_submenu_item = false})
|
|||
|
else
|
|||
|
if cursor.x < self.ax then self:back()
|
|||
|
else self:close() end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_global_mouse_move()
|
|||
|
if self.proximity_raw == 0 then self:select_item_below_cursor()
|
|||
|
else self.current.selected_index = nil end
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_wheel_up()
|
|||
|
self:scroll_to(self.current.scroll_y - self.scroll_step * 3)
|
|||
|
self:on_global_mouse_move() -- selects item below cursor
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_wheel_down()
|
|||
|
self:scroll_to(self.current.scroll_y + self.scroll_step * 3)
|
|||
|
self:on_global_mouse_move() -- selects item below cursor
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_pgup()
|
|||
|
local menu = self.current
|
|||
|
local items_per_page = round((menu.height / self.scroll_step) * 0.4)
|
|||
|
local paged_index = (menu.selected_index and menu.selected_index or #menu.items) - items_per_page
|
|||
|
menu.selected_index = clamp(1, paged_index, #menu.items)
|
|||
|
if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_pgdwn()
|
|||
|
local menu = self.current
|
|||
|
local items_per_page = round((menu.height / self.scroll_step) * 0.4)
|
|||
|
local paged_index = (menu.selected_index and menu.selected_index or 1) + items_per_page
|
|||
|
menu.selected_index = clamp(1, paged_index, #menu.items)
|
|||
|
if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_home()
|
|||
|
self.current.selected_index = math.min(1, #self.current.items)
|
|||
|
if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:on_end()
|
|||
|
self.current.selected_index = #self.current.items
|
|||
|
if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:add_key_binding(key, name, fn, flags)
|
|||
|
self.key_bindings[#self.key_bindings + 1] = name
|
|||
|
mp.add_forced_key_binding(key, name, fn, flags)
|
|||
|
end
|
|||
|
|
|||
|
function Menu:enable_key_bindings()
|
|||
|
-- The `mp.set_key_bindings()` method would be easier here, but that
|
|||
|
-- doesn't support 'repeatable' flag, so we are stuck with this monster.
|
|||
|
self:add_key_binding('up', 'menu-prev1', self:create_action('prev'), 'repeatable')
|
|||
|
self:add_key_binding('down', 'menu-next1', self:create_action('next'), 'repeatable')
|
|||
|
self:add_key_binding('left', 'menu-back1', self:create_action('back'))
|
|||
|
self:add_key_binding('right', 'menu-select1', self:create_action('open_selected_item_preselect'))
|
|||
|
self:add_key_binding('shift+right', 'menu-select-soft1', self:create_action('open_selected_item_soft'))
|
|||
|
self:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_action('open_selected_item_soft'))
|
|||
|
self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_action('back'))
|
|||
|
self:add_key_binding('bs', 'menu-back-alt4', self:create_action('back'))
|
|||
|
self:add_key_binding('enter', 'menu-select-alt3', self:create_action('open_selected_item_preselect'))
|
|||
|
self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_action('open_selected_item_preselect'))
|
|||
|
self:add_key_binding('shift+enter', 'menu-select-alt5', self:create_action('open_selected_item_soft'))
|
|||
|
self:add_key_binding('shift+kp_enter', 'menu-select-alt6', self:create_action('open_selected_item_soft'))
|
|||
|
self:add_key_binding('esc', 'menu-close', self:create_action('close'))
|
|||
|
self:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup'))
|
|||
|
self:add_key_binding('pgdwn', 'menu-page-down', self:create_action('on_pgdwn'))
|
|||
|
self:add_key_binding('home', 'menu-home', self:create_action('on_home'))
|
|||
|
self:add_key_binding('end', 'menu-end', self:create_action('on_end'))
|
|||
|
end
|
|||
|
|
|||
|
function Menu:disable_key_bindings()
|
|||
|
for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end
|
|||
|
self.key_bindings = {}
|
|||
|
end
|
|||
|
|
|||
|
function Menu:create_action(name)
|
|||
|
return function(...) self:maybe(name, ...) end
|
|||
|
end
|
|||
|
|
|||
|
function Menu:render()
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local opacity = options.menu_opacity * self.opacity
|
|||
|
local spacing = self.item_padding
|
|||
|
local icon_size = self.font_size
|
|||
|
|
|||
|
function draw_menu(menu, x, y, opacity)
|
|||
|
local ax, ay, bx, by = x, y, x + menu.width, y + menu.height
|
|||
|
local draw_title = menu.is_root and menu.title
|
|||
|
local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
|
|||
|
local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
|
|||
|
local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
|
|||
|
local selected_index = menu.selected_index or -1
|
|||
|
-- remove menu_opacity to start off with full opacity, but still decay for parent menus
|
|||
|
local text_opacity = opacity / options.menu_opacity
|
|||
|
|
|||
|
-- Background
|
|||
|
ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, {
|
|||
|
color = options.background, opacity = opacity, radius = 4,
|
|||
|
})
|
|||
|
|
|||
|
for index = start_index, end_index, 1 do
|
|||
|
local item = menu.items[index]
|
|||
|
local next_item = menu.items[index + 1]
|
|||
|
local is_highlighted = selected_index == index or item.active
|
|||
|
local next_is_active = next_item and next_item.active
|
|||
|
local next_is_highlighted = selected_index == index + 1 or next_is_active
|
|||
|
|
|||
|
if not item then break end
|
|||
|
|
|||
|
local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
|
|||
|
local item_by = item_ay + self.item_height
|
|||
|
local item_center_y = item_ay + (self.item_height / 2)
|
|||
|
local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
|
|||
|
-- controls title & hint clipping proportional to the ratio of their widths
|
|||
|
local title_hint_ratio = item.hint and item.title_width / (item.title_width + item.hint_width) or 1
|
|||
|
local content_ax, content_bx = ax + spacing, bx - spacing
|
|||
|
local font_color = item.active and options.foreground_text or options.background_text
|
|||
|
local shadow_color = item.active and options.foreground or options.background
|
|||
|
|
|||
|
-- Separator
|
|||
|
local separator_ay = item.separator and item_by - 1 or item_by
|
|||
|
local separator_by = item_by + (item.separator and 2 or 1)
|
|||
|
if is_highlighted then separator_ay = item_by + 1 end
|
|||
|
if next_is_highlighted then separator_by = item_by end
|
|||
|
if separator_by - separator_ay > 0 and item_by < by then
|
|||
|
ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, {
|
|||
|
color = options.foreground, opacity = opacity * (item.separator and 0.08 or 0.06),
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Highlight
|
|||
|
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0)
|
|||
|
if highlight_opacity > 0 then
|
|||
|
ass:rect(ax + 2, item_ay, bx - 2, item_by, {
|
|||
|
radius = 2, color = options.foreground, opacity = highlight_opacity * text_opacity,
|
|||
|
clip = item_clip,
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Icon
|
|||
|
if item.icon then
|
|||
|
ass:icon(content_bx - (icon_size / 2), item_center_y, icon_size * 1.5, item.icon, {
|
|||
|
color = font_color, opacity = text_opacity, clip = item_clip,
|
|||
|
shadow = 1, shadow_color = shadow_color,
|
|||
|
})
|
|||
|
content_bx = content_bx - icon_size - spacing
|
|||
|
end
|
|||
|
|
|||
|
local title_hint_cut_x = content_ax + (content_bx - content_ax - spacing) * title_hint_ratio
|
|||
|
|
|||
|
-- Hint
|
|||
|
if item.hint then
|
|||
|
item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
|
|||
|
local clip = '\\clip(' .. round(title_hint_cut_x + spacing / 2) .. ',' ..
|
|||
|
math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')'
|
|||
|
ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
|
|||
|
size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * opacity, clip = clip,
|
|||
|
shadow = 1, shadow_color = shadow_color,
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Title
|
|||
|
if item.title then
|
|||
|
item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
|
|||
|
local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
|
|||
|
.. round(title_hint_cut_x - spacing / 2) .. ',' .. math.min(item_by, by) .. ')'
|
|||
|
ass:txt(content_ax, item_center_y, 4, item.ass_safe_title, {
|
|||
|
size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2,
|
|||
|
opacity = text_opacity * (item.muted and 0.5 or 1), clip = clip,
|
|||
|
shadow = 1, shadow_color = shadow_color,
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Menu title
|
|||
|
if draw_title then
|
|||
|
local title_ay = ay - self.item_height
|
|||
|
local title_height = self.item_height - 3
|
|||
|
menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title)
|
|||
|
|
|||
|
-- Background
|
|||
|
ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, {
|
|||
|
color = options.foreground, opacity = opacity * 0.8, radius = 2,
|
|||
|
})
|
|||
|
ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', {
|
|||
|
size = 80, color = options.background, opacity = opacity * 0.1,
|
|||
|
})
|
|||
|
|
|||
|
-- Title
|
|||
|
ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.title, {
|
|||
|
size = self.font_size, bold = true, color = options.background, wrap = 2, opacity = opacity,
|
|||
|
clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')',
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Scrollbar
|
|||
|
if menu.scroll_height > 0 then
|
|||
|
local groove_height = menu.height - 2
|
|||
|
local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
|
|||
|
local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
|
|||
|
ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {
|
|||
|
color = options.foreground, opacity = opacity * 0.8,
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Main menu
|
|||
|
draw_menu(self.current, self.ax, self.ay, opacity)
|
|||
|
|
|||
|
-- Parent menus
|
|||
|
local parent_menu = self.current.parent_menu
|
|||
|
local parent_offset_x = self.ax
|
|||
|
local parent_opacity_factor = options.menu_parent_opacity
|
|||
|
local menu_gap = 2
|
|||
|
|
|||
|
while parent_menu do
|
|||
|
parent_offset_x = parent_offset_x - parent_menu.width - menu_gap
|
|||
|
draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity)
|
|||
|
parent_opacity_factor = parent_opacity_factor * parent_opacity_factor
|
|||
|
parent_menu = parent_menu.parent_menu
|
|||
|
end
|
|||
|
|
|||
|
-- Selected menu
|
|||
|
local selected_menu = self.current.items[self.current.selected_index]
|
|||
|
|
|||
|
if selected_menu and selected_menu.items then
|
|||
|
draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity)
|
|||
|
end
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ Speed ]]
|
|||
|
|
|||
|
---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
|
|||
|
|
|||
|
---@class Speed : Element
|
|||
|
local Speed = class(Element)
|
|||
|
|
|||
|
---@param props? ElementProps
|
|||
|
function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
|
|||
|
function Speed:init(props)
|
|||
|
Element.init(self, 'speed', props)
|
|||
|
|
|||
|
self.width = 0
|
|||
|
self.height = 0
|
|||
|
self.notches = 10
|
|||
|
self.notch_every = 0.1
|
|||
|
---@type number
|
|||
|
self.notch_spacing = nil
|
|||
|
---@type number
|
|||
|
self.font_size = nil
|
|||
|
---@type Dragging|nil
|
|||
|
self.dragging = nil
|
|||
|
end
|
|||
|
|
|||
|
function Speed:get_visibility()
|
|||
|
-- We force inherit, because I want to see speed value when peeking timeline
|
|||
|
local this_visibility = Element.get_visibility(self)
|
|||
|
return Elements.timeline.proximity_raw ~= 0
|
|||
|
and math.max(Elements.timeline.proximity, this_visibility) or this_visibility
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_coordinates()
|
|||
|
self.height, self.width = self.by - self.ay, self.bx - self.ax
|
|||
|
self.notch_spacing = self.width / (self.notches + 1)
|
|||
|
self.font_size = round(self.height * 0.48 * options.font_scale)
|
|||
|
end
|
|||
|
|
|||
|
function Speed:speed_step(speed, up)
|
|||
|
if options.speed_step_is_factor then
|
|||
|
if up then
|
|||
|
return speed * options.speed_step
|
|||
|
else
|
|||
|
return speed * 1 / options.speed_step
|
|||
|
end
|
|||
|
else
|
|||
|
if up then
|
|||
|
return speed + options.speed_step
|
|||
|
else
|
|||
|
return speed - options.speed_step
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_mbtn_left_down()
|
|||
|
self:tween_stop() -- Stop and cleanup possible ongoing animations
|
|||
|
self.dragging = {
|
|||
|
start_time = mp.get_time(),
|
|||
|
start_x = cursor.x,
|
|||
|
distance = 0,
|
|||
|
speed_distance = 0,
|
|||
|
start_speed = state.speed,
|
|||
|
}
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_global_mouse_move()
|
|||
|
if not self.dragging then return end
|
|||
|
|
|||
|
self.dragging.distance = cursor.x - self.dragging.start_x
|
|||
|
self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every)
|
|||
|
|
|||
|
local speed_current = state.speed
|
|||
|
local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance
|
|||
|
speed_drag_current = clamp(0.01, speed_drag_current, 100)
|
|||
|
local drag_dir_up = speed_drag_current > speed_current
|
|||
|
|
|||
|
local speed_step_next = speed_current
|
|||
|
local speed_drag_diff = math.abs(speed_drag_current - speed_current)
|
|||
|
while math.abs(speed_step_next - speed_current) < speed_drag_diff do
|
|||
|
speed_step_next = self:speed_step(speed_step_next, drag_dir_up)
|
|||
|
end
|
|||
|
local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up)
|
|||
|
|
|||
|
local speed_new = speed_step_prev
|
|||
|
local speed_next_diff = math.abs(speed_drag_current - speed_step_next)
|
|||
|
local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev)
|
|||
|
if speed_next_diff < speed_prev_diff then
|
|||
|
speed_new = speed_step_next
|
|||
|
end
|
|||
|
|
|||
|
if speed_new ~= speed_current then
|
|||
|
mp.set_property_native('speed', speed_new)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_mbtn_left_up()
|
|||
|
-- Reset speed on short clicks
|
|||
|
if self.dragging and math.abs(self.dragging.distance) < 6 and mp.get_time() - self.dragging.start_time < 0.15 then
|
|||
|
mp.set_property_native('speed', 1)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_global_mbtn_left_up()
|
|||
|
self.dragging = nil
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_global_mouse_leave()
|
|||
|
self.dragging = nil
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Speed:on_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
|
|||
|
function Speed:on_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end
|
|||
|
|
|||
|
function Speed:render()
|
|||
|
if not self.dragging and (Elements.curtain.opacity > 0) then return end
|
|||
|
|
|||
|
local visibility = self:get_visibility()
|
|||
|
local opacity = self.dragging and 1 or visibility
|
|||
|
|
|||
|
if opacity <= 0 then return end
|
|||
|
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
|
|||
|
-- Background
|
|||
|
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
|||
|
color = options.background, radius = 2, opacity = opacity * options.speed_opacity,
|
|||
|
})
|
|||
|
|
|||
|
-- Coordinates
|
|||
|
local ax, ay = self.ax, self.ay
|
|||
|
local bx, by = self.bx, ay + self.height
|
|||
|
local half_width = (self.width / 2)
|
|||
|
local half_x = ax + half_width
|
|||
|
|
|||
|
-- Notches
|
|||
|
local speed_at_center = state.speed
|
|||
|
if self.dragging then
|
|||
|
speed_at_center = self.dragging.start_speed + self.dragging.speed_distance
|
|||
|
speed_at_center = clamp(0.01, speed_at_center, 100)
|
|||
|
end
|
|||
|
local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every
|
|||
|
local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing)
|
|||
|
local guide_size = math.floor(self.height / 7.5)
|
|||
|
local notch_by = by - guide_size
|
|||
|
local notch_ay_big = ay + round(self.font_size * 1.1)
|
|||
|
local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
|
|||
|
local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
|
|||
|
local from_to_index = math.floor(self.notches / 2)
|
|||
|
|
|||
|
for i = -from_to_index, from_to_index do
|
|||
|
local notch_speed = nearest_notch_speed + (i * self.notch_every)
|
|||
|
|
|||
|
if notch_speed >= 0 and notch_speed <= 100 then
|
|||
|
local notch_x = nearest_notch_x + (i * self.notch_spacing)
|
|||
|
local notch_thickness = 1
|
|||
|
local notch_ay = notch_ay_small
|
|||
|
if (notch_speed % (self.notch_every * 10)) < 0.00000001 then
|
|||
|
notch_ay = notch_ay_big
|
|||
|
notch_thickness = 1.5
|
|||
|
elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then
|
|||
|
notch_ay = notch_ay_medium
|
|||
|
end
|
|||
|
|
|||
|
ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, {
|
|||
|
color = options.foreground, border = 1, border_color = options.background,
|
|||
|
opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity,
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Center guide
|
|||
|
ass:new_event()
|
|||
|
ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. options.foreground .. '\\3c&H' .. options.background .. '}')
|
|||
|
ass:opacity(opacity)
|
|||
|
ass:pos(0, 0)
|
|||
|
ass:draw_start()
|
|||
|
ass:move_to(half_x, by - 2 - guide_size)
|
|||
|
ass:line_to(half_x + guide_size, by - 2)
|
|||
|
ass:line_to(half_x - guide_size, by - 2)
|
|||
|
ass:draw_stop()
|
|||
|
|
|||
|
-- Speed value
|
|||
|
local speed_text = (round(state.speed * 100) / 100) .. 'x'
|
|||
|
ass:txt(half_x, ay, 8, speed_text, {
|
|||
|
size = self.font_size, color = options.background_text,
|
|||
|
border = options.text_border, border_color = options.background, opacity = opacity,
|
|||
|
})
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ Button ]]
|
|||
|
|
|||
|
---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
|
|||
|
|
|||
|
---@class Button : Element
|
|||
|
local Button = class(Element)
|
|||
|
|
|||
|
---@param id string
|
|||
|
---@param props ButtonProps
|
|||
|
function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
|
|||
|
---@param id string
|
|||
|
---@param props ButtonProps
|
|||
|
function Button:init(id, props)
|
|||
|
self.icon = props.icon
|
|||
|
self.active = props.active
|
|||
|
self.tooltip = props.tooltip
|
|||
|
self.badge = props.badge
|
|||
|
self.foreground = props.foreground or options.foreground
|
|||
|
self.background = props.background or options.background
|
|||
|
---@type fun()
|
|||
|
self.on_click = props.on_click
|
|||
|
Element.init(self, id, props)
|
|||
|
end
|
|||
|
|
|||
|
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
|||
|
function Button:on_mbtn_left_down()
|
|||
|
-- We delay the callback to next tick, otherwise we are risking race
|
|||
|
-- conditions as we are in the middle of event dispatching.
|
|||
|
-- For example, handler might add a menu to the end of the element stack, and that
|
|||
|
-- than picks up this click even we are in right now, and instantly closes itself.
|
|||
|
mp.add_timeout(0.01, self.on_click)
|
|||
|
end
|
|||
|
|
|||
|
function Button:render()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
if visibility <= 0 then return end
|
|||
|
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local is_hover = self.proximity_raw == 0
|
|||
|
local is_hover_or_active = is_hover or self.active
|
|||
|
local foreground = self.active and self.background or self.foreground
|
|||
|
local background = self.active and self.foreground or self.background
|
|||
|
|
|||
|
-- Background
|
|||
|
if is_hover_or_active then
|
|||
|
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
|||
|
color = self.active and background or foreground, radius = 2,
|
|||
|
opacity = visibility * (self.active and 0.8 or 0.3),
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Tooltip on hover
|
|||
|
if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
|
|||
|
|
|||
|
-- Badge
|
|||
|
local icon_clip
|
|||
|
if self.badge then
|
|||
|
local badge_font_size = self.font_size * 0.6
|
|||
|
local badge_width = text_width_estimate(self.badge, badge_font_size)
|
|||
|
local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
|
|||
|
local bx, by = self.bx - 1, self.by - 1
|
|||
|
ass:rect(bx - width, by - height, bx, by, {
|
|||
|
color = foreground, radius = 2, opacity = visibility,
|
|||
|
border = self.active and 0 or 1, border_color = background,
|
|||
|
})
|
|||
|
ass:txt(bx - width / 2, by - height / 2, 5, self.badge, {
|
|||
|
size = badge_font_size, color = background, opacity = visibility,
|
|||
|
})
|
|||
|
|
|||
|
local clip_border = math.max(self.font_size / 20, 1)
|
|||
|
local clip_path = assdraw.ass_new()
|
|||
|
clip_path:round_rect_cw(
|
|||
|
math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3
|
|||
|
)
|
|||
|
icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')'
|
|||
|
end
|
|||
|
|
|||
|
-- Icon
|
|||
|
local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2)
|
|||
|
ass:icon(x, y, self.font_size, self.icon, {
|
|||
|
color = foreground, border = self.active and 0 or options.text_border, border_color = background,
|
|||
|
opacity = visibility, clip = icon_clip,
|
|||
|
})
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ CycleButton ]]
|
|||
|
|
|||
|
---@alias CycleState {value: any; icon: string; active?: boolean}
|
|||
|
---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
|
|||
|
|
|||
|
---@class CycleButton : Button
|
|||
|
local CycleButton = class(Button)
|
|||
|
|
|||
|
---@param id string
|
|||
|
---@param props CycleButtonProps
|
|||
|
function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
|
|||
|
---@param id string
|
|||
|
---@param props CycleButtonProps
|
|||
|
function CycleButton:init(id, props)
|
|||
|
local is_state_prop = itable_index_of({'shuffle'}, props.prop)
|
|||
|
self.prop = props.prop
|
|||
|
self.states = props.states
|
|||
|
|
|||
|
Button.init(self, id, props)
|
|||
|
|
|||
|
self.icon = self.states[1].icon
|
|||
|
self.active = self.states[1].active
|
|||
|
self.current_state_index = 1
|
|||
|
self.on_click = function()
|
|||
|
local new_state = self.states[self.current_state_index + 1] or self.states[1]
|
|||
|
if is_state_prop then
|
|||
|
local new_value = new_state.value
|
|||
|
if itable_index_of({'yes', 'no'}, new_state.value) then new_value = new_value == 'yes' end
|
|||
|
set_state(self.prop, new_value)
|
|||
|
else
|
|||
|
mp.set_property(self.prop, new_state.value)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
self.handle_change = function(name, value)
|
|||
|
if is_state_prop and type(value) == 'boolean' then value = value and 'yes' or 'no' end
|
|||
|
local index = itable_find(self.states, function(state) return state.value == value end)
|
|||
|
self.current_state_index = index or 1
|
|||
|
self.icon = self.states[self.current_state_index].icon
|
|||
|
self.active = self.states[self.current_state_index].active
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
-- Built in state props
|
|||
|
if is_state_prop then
|
|||
|
self['on_prop_' .. self.prop] = function(self, value) self.handle_change(self.prop, value) end
|
|||
|
else
|
|||
|
mp.observe_property(self.prop, 'string', self.handle_change)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function CycleButton:destroy()
|
|||
|
Button.destroy(self)
|
|||
|
mp.unobserve_property(self.handle_change)
|
|||
|
end
|
|||
|
|
|||
|
--[[ WindowBorder ]]
|
|||
|
|
|||
|
---@class WindowBorder : Element
|
|||
|
local WindowBorder = class(Element)
|
|||
|
|
|||
|
function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end
|
|||
|
function WindowBorder:init()
|
|||
|
Element.init(self, 'window_border')
|
|||
|
self.ignores_menu = true
|
|||
|
self.size = 0
|
|||
|
end
|
|||
|
|
|||
|
function WindowBorder:decide_enabled()
|
|||
|
self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border
|
|||
|
self.size = self.enabled and options.window_border_size or 0
|
|||
|
end
|
|||
|
|
|||
|
function WindowBorder:on_prop_border() self:decide_enabled() end
|
|||
|
function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end
|
|||
|
|
|||
|
function WindowBorder:render()
|
|||
|
if self.size > 0 then
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
|
|||
|
(display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
|
|||
|
ass:rect(0, 0, display.width + 1, display.height + 1, {
|
|||
|
color = options.background, clip = clip, opacity = options.window_border_opacity,
|
|||
|
})
|
|||
|
return ass
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
--[[ PauseIndicator ]]
|
|||
|
|
|||
|
---@class PauseIndicator : Element
|
|||
|
local PauseIndicator = class(Element)
|
|||
|
|
|||
|
function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end
|
|||
|
function PauseIndicator:init()
|
|||
|
Element.init(self, 'pause_indicator')
|
|||
|
self.ignores_menu = true
|
|||
|
self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
|
|||
|
self.paused = state.pause
|
|||
|
self.type = options.pause_indicator
|
|||
|
self.is_manual = options.pause_indicator == 'manual'
|
|||
|
self.fadeout_requested = false
|
|||
|
self.opacity = 0
|
|||
|
|
|||
|
mp.observe_property('pause', 'bool', function(_, paused)
|
|||
|
if options.pause_indicator == 'flash' then
|
|||
|
if self.paused == paused then return end
|
|||
|
self:flash()
|
|||
|
elseif options.pause_indicator == 'static' then
|
|||
|
self:decide()
|
|||
|
end
|
|||
|
end)
|
|||
|
end
|
|||
|
|
|||
|
function PauseIndicator:flash()
|
|||
|
if not self.is_manual and self.type ~= 'flash' then return end
|
|||
|
-- can't wait for pause property event listener to set this, because when this is used inside a binding like:
|
|||
|
-- cycle pause; script-binding uosc/flash-pause-indicator
|
|||
|
-- the pause event is not fired fast enough, and indicator starts rendering with old icon
|
|||
|
self.paused = mp.get_property_native('pause')
|
|||
|
if self.is_manual then self.type = 'flash' end
|
|||
|
self.opacity = 1
|
|||
|
self:tween_property('opacity', 1, 0, 0.15)
|
|||
|
end
|
|||
|
|
|||
|
-- decides whether static indicator should be visible or not
|
|||
|
function PauseIndicator:decide()
|
|||
|
if not self.is_manual and self.type ~= 'static' then return end
|
|||
|
self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
|
|||
|
if self.is_manual then self.type = 'static' end
|
|||
|
self.opacity = self.paused and 1 or 0
|
|||
|
request_render()
|
|||
|
|
|||
|
-- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored.
|
|||
|
-- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more
|
|||
|
mp.add_timeout(.05, function() osd:update() end)
|
|||
|
end
|
|||
|
|
|||
|
function PauseIndicator:render()
|
|||
|
if self.opacity == 0 then return end
|
|||
|
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local is_static = self.type == 'static'
|
|||
|
|
|||
|
-- Background fadeout
|
|||
|
if is_static then
|
|||
|
ass:rect(0, 0, display.width, display.height, {color = options.background, opacity = self.opacity * 0.3})
|
|||
|
end
|
|||
|
|
|||
|
-- Icon
|
|||
|
local size = round(math.min(display.width, display.height) * (is_static and 0.20 or 0.15))
|
|||
|
size = size + size * (1 - self.opacity)
|
|||
|
|
|||
|
if self.paused then
|
|||
|
ass:icon(display.width / 2, display.height / 2, size, 'pause',
|
|||
|
{border = 1, opacity = self.base_icon_opacity * self.opacity}
|
|||
|
)
|
|||
|
else
|
|||
|
ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow',
|
|||
|
{border = 1, opacity = self.base_icon_opacity * self.opacity}
|
|||
|
)
|
|||
|
end
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ Timeline ]]
|
|||
|
|
|||
|
---@class Timeline : Element
|
|||
|
local Timeline = class(Element)
|
|||
|
|
|||
|
function Timeline:new() return Class.new(self) --[[@as Timeline]] end
|
|||
|
function Timeline:init()
|
|||
|
Element.init(self, 'timeline')
|
|||
|
self.pressed = false
|
|||
|
self.size_max = 0
|
|||
|
self.size_min = 0
|
|||
|
self.size_min_override = options.timeline_start_hidden and 0 or nil
|
|||
|
self.font_size = 0
|
|||
|
self.top_border = options.timeline_border
|
|||
|
|
|||
|
-- Release any dragging when file gets unloaded
|
|||
|
mp.register_event('end-file', function() self.pressed = false end)
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:get_visibility()
|
|||
|
return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self))
|
|||
|
or Element.get_visibility(self)
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:decide_enabled()
|
|||
|
self.enabled = state.duration and state.duration > 0 and state.time
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:get_effective_size_min()
|
|||
|
return self.size_min_override or self.size_min
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:get_effective_size()
|
|||
|
if Elements.speed and Elements.speed.dragging then return self.size_max end
|
|||
|
local size_min = self:get_effective_size_min()
|
|||
|
return size_min + math.ceil((self.size_max - size_min) * self:get_visibility())
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:get_effective_line_width()
|
|||
|
return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:update_dimensions()
|
|||
|
if state.fullormaxed then
|
|||
|
self.size_min = options.timeline_size_min_fullscreen
|
|||
|
self.size_max = options.timeline_size_max_fullscreen
|
|||
|
else
|
|||
|
self.size_min = options.timeline_size_min
|
|||
|
self.size_max = options.timeline_size_max
|
|||
|
end
|
|||
|
self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale)
|
|||
|
self.ax = Elements.window_border.size
|
|||
|
self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border
|
|||
|
self.bx = display.width - Elements.window_border.size
|
|||
|
self.by = display.height - Elements.window_border.size
|
|||
|
self.width = self.bx - self.ax
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:get_time_at_x(x)
|
|||
|
-- line width 1 for timeline_style=bar so mouse input can go all the way from 0 to 1 progress
|
|||
|
local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() or 1)
|
|||
|
local time_width = self.width - line_width
|
|||
|
local progress_x = x - self.ax - line_width / 2
|
|||
|
local progress = clamp(0, progress_x / time_width, 1)
|
|||
|
return state.duration * progress
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:set_from_cursor()
|
|||
|
mp.commandv('seek', self:get_time_at_x(cursor.x), 'absolute+exact')
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:on_mbtn_left_down()
|
|||
|
self.pressed = true
|
|||
|
self:set_from_cursor()
|
|||
|
end
|
|||
|
|
|||
|
function Timeline:on_prop_duration() self:decide_enabled() end
|
|||
|
function Timeline:on_prop_time() self:decide_enabled() end
|
|||
|
function Timeline:on_prop_border() self:update_dimensions() end
|
|||
|
function Timeline:on_prop_fullormaxed() self:update_dimensions() end
|
|||
|
function Timeline:on_display() self:update_dimensions() end
|
|||
|
function Timeline:on_mouse_leave() mp.commandv('script-message-to', 'thumbfast', 'clear') end
|
|||
|
function Timeline:on_global_mbtn_left_up() self.pressed = false end
|
|||
|
function Timeline:on_global_mouse_leave() self.pressed = false end
|
|||
|
function Timeline:on_global_mouse_move()
|
|||
|
if self.pressed then self:set_from_cursor() end
|
|||
|
end
|
|||
|
function Timeline:on_wheel_up() mp.commandv('seek', options.timeline_step) end
|
|||
|
function Timeline:on_wheel_down() mp.commandv('seek', -options.timeline_step) end
|
|||
|
|
|||
|
function Timeline:render()
|
|||
|
if self.size_max == 0 then return end
|
|||
|
|
|||
|
local size_min = self:get_effective_size_min()
|
|||
|
local size = self:get_effective_size()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
|
|||
|
if size < 1 then return end
|
|||
|
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
|
|||
|
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
|
|||
|
local hide_text_below = math.max(self.font_size * 0.8, size_min * 2)
|
|||
|
local hide_text_ramp = hide_text_below / 2
|
|||
|
local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
|
|||
|
|
|||
|
local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4)
|
|||
|
local progress = state.time / state.duration
|
|||
|
local is_line = options.timeline_style == 'line'
|
|||
|
|
|||
|
-- Foreground & Background bar coordinates
|
|||
|
local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
|
|||
|
local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
|
|||
|
local fcy = fay + (size / 2)
|
|||
|
|
|||
|
local time_x = bax + self.width * progress
|
|||
|
local line_width, past_x_adjustment, future_x_adjustment = 0, 1, 1
|
|||
|
|
|||
|
if is_line then
|
|||
|
local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1)
|
|||
|
local width_normal = self:get_effective_line_width()
|
|||
|
local max_min_width_delta = size_min > 0
|
|||
|
and width_normal - width_normal * options.timeline_line_width_minimized_scale
|
|||
|
or 0
|
|||
|
line_width = width_normal - (max_min_width_delta * minimized_fraction)
|
|||
|
fax = bax + (self.width - line_width) * progress
|
|||
|
fbx = fax + line_width
|
|||
|
local past_time_width, future_time_width = time_x - bax, bbx - time_x
|
|||
|
past_x_adjustment = (past_time_width - (time_x - fax)) / past_time_width
|
|||
|
future_x_adjustment = (future_time_width - (fbx - time_x)) / future_time_width
|
|||
|
else
|
|||
|
fax, fbx = bax, bax + self.width * progress
|
|||
|
end
|
|||
|
|
|||
|
local foreground_size = fby - fay
|
|||
|
local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
|
|||
|
|
|||
|
-- line_x_adjustment: adjusts x coordinate so that it never lies inside of the line
|
|||
|
-- it's as if line cuts the timeline and squeezes itself into the cut
|
|||
|
local lxa = line_width == 0 and function(x) return x end or function(x)
|
|||
|
return x < time_x and bax + (x - bax) * past_x_adjustment or bbx - (bbx - x) * future_x_adjustment
|
|||
|
end
|
|||
|
|
|||
|
-- Background
|
|||
|
ass:new_event()
|
|||
|
ass:pos(0, 0)
|
|||
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.background .. '}')
|
|||
|
ass:opacity(math.max(options.timeline_opacity - 0.1, 0))
|
|||
|
ass:draw_start()
|
|||
|
ass:rect_cw(bax, bay, fax, bby) --left of progress
|
|||
|
ass:rect_cw(fbx, bay, bbx, bby) --right of progress
|
|||
|
ass:rect_cw(fax, bay, fbx, fay) --above progress
|
|||
|
ass:draw_stop()
|
|||
|
|
|||
|
-- Progress
|
|||
|
ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity})
|
|||
|
|
|||
|
-- Uncached ranges
|
|||
|
local buffered_time = nil
|
|||
|
if state.uncached_ranges then
|
|||
|
local opts = {size = 80, anchor_y = fby}
|
|||
|
local texture_char = visibility > 0 and 'b' or 'a'
|
|||
|
local offset = opts.size / (visibility > 0 and 24 or 28)
|
|||
|
for _, range in ipairs(state.uncached_ranges) do
|
|||
|
if not buffered_time and (range[1] > state.time or range[2] > state.time) then
|
|||
|
buffered_time = range[1] - state.time
|
|||
|
end
|
|||
|
local ax = range[1] < 0.5 and bax or math.floor(lxa(bax + self.width * (range[1] / state.duration)))
|
|||
|
local bx = range[2] > state.duration - 0.5 and bbx or
|
|||
|
math.ceil(lxa(bax + self.width * (range[2] / state.duration)))
|
|||
|
opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
|
|||
|
ass:texture(ax, fay, bx, fby, texture_char, opts)
|
|||
|
opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
|
|||
|
ass:texture(ax, fay, bx, fby, texture_char, opts)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Custom ranges
|
|||
|
for _, chapter_range in ipairs(state.chapter_ranges) do
|
|||
|
local rax = chapter_range.start < 0.1 and 0 or lxa(bax + self.width * (chapter_range.start / state.duration))
|
|||
|
local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
|
|||
|
or lxa(bax + self.width * math.min(chapter_range['end'] / state.duration, 1))
|
|||
|
ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
|
|||
|
end
|
|||
|
|
|||
|
-- Chapters
|
|||
|
if (options.timeline_chapters_opacity > 0
|
|||
|
and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)
|
|||
|
) then
|
|||
|
local diamond_radius = foreground_size < 3 and foreground_size or math.max(foreground_size / 10, 3)
|
|||
|
local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
|
|||
|
|
|||
|
if diamond_radius > 0 then
|
|||
|
local function draw_chapter(time)
|
|||
|
local chapter_x = bax + line_width / 2 + (self.width - line_width) * (time / state.duration)
|
|||
|
local chapter_y = fay - 1
|
|||
|
ass:new_event()
|
|||
|
ass:append(string.format(
|
|||
|
'{\\pos(0,0)\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
|
|||
|
diamond_border, options.foreground, options.background, options.background,
|
|||
|
opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity)
|
|||
|
))
|
|||
|
ass:draw_start()
|
|||
|
ass:move_to(chapter_x - diamond_radius, chapter_y)
|
|||
|
ass:line_to(chapter_x, chapter_y - diamond_radius)
|
|||
|
ass:line_to(chapter_x + diamond_radius, chapter_y)
|
|||
|
ass:line_to(chapter_x, chapter_y + diamond_radius)
|
|||
|
ass:draw_stop()
|
|||
|
end
|
|||
|
|
|||
|
if state.chapters ~= nil then
|
|||
|
for i, chapter in ipairs(state.chapters) do
|
|||
|
if not chapter.is_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
|
|||
|
|
|||
|
local function draw_timeline_text(x, y, align, text, opts)
|
|||
|
opts.color, opts.border_color = options.foreground_text, options.foreground
|
|||
|
opts.clip = '\\clip(' .. foreground_coordinates .. ')'
|
|||
|
ass:txt(x, y, align, text, opts)
|
|||
|
opts.color, opts.border_color = options.background_text, options.background
|
|||
|
opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
|
|||
|
ass:txt(x, y, align, text, opts)
|
|||
|
end
|
|||
|
|
|||
|
-- Time values
|
|||
|
if text_opacity > 0 then
|
|||
|
-- Upcoming cache time
|
|||
|
if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then
|
|||
|
local x, align = fbx + 5, 4
|
|||
|
local font_size = self.font_size * 0.8
|
|||
|
local human = round(math.max(buffered_time, 0)) .. 's'
|
|||
|
local width = text_width_estimate(human, font_size)
|
|||
|
local min_x = bax + 5 + text_width_estimate(state.time_human, self.font_size)
|
|||
|
local max_x = bbx - 5 - text_width_estimate(state.duration_or_remaining_time_human, self.font_size)
|
|||
|
if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
|
|||
|
draw_timeline_text(x, fcy, align, human, {size = font_size, opacity = text_opacity * 0.6, border = 1})
|
|||
|
end
|
|||
|
|
|||
|
local opts = {size = self.font_size, opacity = text_opacity, border = 2}
|
|||
|
|
|||
|
-- Elapsed time
|
|||
|
if state.time_human then
|
|||
|
draw_timeline_text(bax + spacing, fcy, 4, state.time_human, opts)
|
|||
|
end
|
|||
|
|
|||
|
-- End time
|
|||
|
if state.duration_or_remaining_time_human then
|
|||
|
draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, opts)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Hovered time and chapter
|
|||
|
if (self.proximity_raw == 0 or self.pressed) and not (Elements.speed and Elements.speed.dragging) then
|
|||
|
local hovered_seconds = self:get_time_at_x(cursor.x)
|
|||
|
|
|||
|
-- Cursor line
|
|||
|
-- 0.5 to switch when the pixel is half filled in
|
|||
|
local color = ((fax - 0.5) < cursor.x and cursor.x < (fbx + 0.5)) and
|
|||
|
options.background or options.foreground
|
|||
|
local ax, ay, bx, by = cursor.x - 0.5, fay, cursor.x + 0.5, fby
|
|||
|
ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2})
|
|||
|
local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by}
|
|||
|
|
|||
|
-- Timestamp
|
|||
|
ass:tooltip(tooltip_anchor, format_time(hovered_seconds), {size = self.font_size, offset = 4})
|
|||
|
tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - 4
|
|||
|
|
|||
|
-- Thumbnail
|
|||
|
if not thumbnail.disabled and thumbnail.width ~= 0 and thumbnail.height ~= 0 then
|
|||
|
local scale_x, scale_y = display.scale_x, display.scale_y
|
|||
|
local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y)
|
|||
|
local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y
|
|||
|
local thumb_width, thumb_height = thumbnail.width, thumbnail.height
|
|||
|
local thumb_x = round(clamp(
|
|||
|
thumb_x_margin, cursor.x * scale_x - thumb_width / 2,
|
|||
|
display.width * scale_x - thumb_width - thumb_x_margin
|
|||
|
))
|
|||
|
local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height)
|
|||
|
local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y
|
|||
|
local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y
|
|||
|
ass:rect(ax, ay, bx, by, {
|
|||
|
color = options.foreground, border = 1, border_color = options.background, radius = 3, opacity = 0.8,
|
|||
|
})
|
|||
|
mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
|
|||
|
tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay
|
|||
|
end
|
|||
|
|
|||
|
-- Chapter title
|
|||
|
if #state.chapters > 0 then
|
|||
|
local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true)
|
|||
|
if chapter and not chapter.is_end_only then
|
|||
|
ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
|
|||
|
size = self.font_size, offset = 10, responsive = false, bold = true,
|
|||
|
text_length_override = chapter.title_wrapped_width,
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ TopBarButton ]]
|
|||
|
|
|||
|
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
|
|||
|
|
|||
|
---@class TopBarButton : Element
|
|||
|
local TopBarButton = class(Element)
|
|||
|
|
|||
|
---@param id string
|
|||
|
---@param props TopBarButtonProps
|
|||
|
function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
|
|||
|
function TopBarButton:init(id, props)
|
|||
|
Element.init(self, id, props)
|
|||
|
self.anchor_id = 'top_bar'
|
|||
|
self.icon = props.icon
|
|||
|
self.background = props.background
|
|||
|
self.command = props.command
|
|||
|
end
|
|||
|
|
|||
|
function TopBarButton:on_mbtn_left_down()
|
|||
|
mp.command(type(self.command) == 'function' and self.command() or self.command)
|
|||
|
end
|
|||
|
|
|||
|
function TopBarButton:render()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
if visibility <= 0 then return end
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
|
|||
|
-- Background on hover
|
|||
|
if self.proximity_raw == 0 then
|
|||
|
ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
|
|||
|
end
|
|||
|
|
|||
|
local width, height = self.bx - self.ax, self.by - self.ay
|
|||
|
local icon_size = math.min(width, height) * 0.5
|
|||
|
ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
|
|||
|
opacity = visibility, border = options.text_border,
|
|||
|
})
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ TopBar ]]
|
|||
|
|
|||
|
---@class TopBar : Element
|
|||
|
local TopBar = class(Element)
|
|||
|
|
|||
|
function TopBar:new() return Class.new(self) --[[@as TopBar]] end
|
|||
|
function TopBar:init()
|
|||
|
Element.init(self, 'top_bar')
|
|||
|
self.pressed = false
|
|||
|
self.size, self.size_max, self.size_min = 0, 0, 0
|
|||
|
self.icon_size, self.spacing, self.font_size, self.title_bx = 1, 1, 1, 1
|
|||
|
self.size_min_override = options.timeline_start_hidden and 0 or nil
|
|||
|
self.top_border = options.timeline_border
|
|||
|
|
|||
|
local function decide_maximized_command()
|
|||
|
return state.border
|
|||
|
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
|||
|
or 'set window-maximized no;cycle fullscreen'
|
|||
|
end
|
|||
|
|
|||
|
-- Order aligns from right to left
|
|||
|
self.buttons = {
|
|||
|
TopBarButton:new('tb_close', {icon = 'close', background = '2311e8', command = 'quit'}),
|
|||
|
TopBarButton:new('tb_max', {icon = 'crop_square', background = '222222', command = decide_maximized_command}),
|
|||
|
TopBarButton:new('tb_min', {icon = 'minimize', background = '222222', command = 'cycle window-minimized'}),
|
|||
|
}
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:decide_enabled()
|
|||
|
if options.top_bar == 'no-border' then
|
|||
|
self.enabled = not state.border or state.fullscreen
|
|||
|
else
|
|||
|
self.enabled = options.top_bar == 'always'
|
|||
|
end
|
|||
|
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title)
|
|||
|
for _, element in ipairs(self.buttons) do
|
|||
|
element.enabled = self.enabled and options.top_bar_controls
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:update_dimensions()
|
|||
|
self.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size
|
|||
|
self.icon_size = round(self.size * 0.5)
|
|||
|
self.spacing = math.ceil(self.size * 0.25)
|
|||
|
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
|
|||
|
self.button_width = round(self.size * 1.15)
|
|||
|
self.ay = Elements.window_border.size
|
|||
|
self.bx = display.width - Elements.window_border.size
|
|||
|
self.by = self.size + Elements.window_border.size
|
|||
|
self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
|
|||
|
self.ax = options.top_bar_title and Elements.window_border.size or self.title_bx
|
|||
|
|
|||
|
local button_bx = self.bx
|
|||
|
for _, element in pairs(self.buttons) do
|
|||
|
element.ax, element.bx = button_bx - self.button_width, button_bx
|
|||
|
element.ay, element.by = self.ay, self.by
|
|||
|
button_bx = button_bx - self.button_width
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:on_prop_border()
|
|||
|
self:decide_enabled()
|
|||
|
self:update_dimensions()
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:on_prop_fullscreen()
|
|||
|
self:decide_enabled()
|
|||
|
self:update_dimensions()
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:on_prop_maximized()
|
|||
|
self:decide_enabled()
|
|||
|
self:update_dimensions()
|
|||
|
end
|
|||
|
|
|||
|
function TopBar:on_display() self:update_dimensions() end
|
|||
|
|
|||
|
function TopBar:render()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
if visibility <= 0 then return end
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
|
|||
|
-- Window title
|
|||
|
if options.top_bar_title and (state.title or state.has_playlist) then
|
|||
|
local bg_margin = math.floor((self.size - self.font_size) / 4)
|
|||
|
local padding = self.font_size / 2
|
|||
|
local title_ax = self.ax + bg_margin
|
|||
|
local title_ay = self.ay + bg_margin
|
|||
|
local max_bx = self.title_bx - self.spacing
|
|||
|
|
|||
|
-- Playlist position
|
|||
|
if state.has_playlist then
|
|||
|
local text = state.playlist_pos .. '' .. state.playlist_count
|
|||
|
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
|
|||
|
.. state.playlist_count
|
|||
|
local bx = round(title_ax + text_length_width_estimate(#text, self.font_size) + padding * 2)
|
|||
|
ass:rect(title_ax, title_ay, bx, self.by - bg_margin, {
|
|||
|
color = options.foreground, opacity = visibility, radius = 2,
|
|||
|
})
|
|||
|
ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, {
|
|||
|
size = self.font_size, wrap = 2, color = options.background, opacity = visibility,
|
|||
|
})
|
|||
|
title_ax = bx + bg_margin
|
|||
|
end
|
|||
|
|
|||
|
-- Title
|
|||
|
if max_bx - title_ax > self.font_size * 3 then
|
|||
|
local text = state.title or 'n/a'
|
|||
|
local bx = math.min(max_bx, title_ax + text_width_estimate(text, self.font_size) + padding * 2)
|
|||
|
local by = self.by - bg_margin
|
|||
|
ass:rect(title_ax, title_ay, bx, by, {
|
|||
|
color = options.background, opacity = visibility * options.top_bar_title_opacity, radius = 2,
|
|||
|
})
|
|||
|
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, {
|
|||
|
size = self.font_size, wrap = 2, color = options.foreground, border = 1, border_color = options.background,
|
|||
|
opacity = visibility, clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
|
|||
|
})
|
|||
|
title_ay = by + 1
|
|||
|
end
|
|||
|
|
|||
|
-- Subtitle: current chapter
|
|||
|
if state.current_chapter and max_bx - title_ax > self.font_size * 3 then
|
|||
|
local font_size = self.font_size * 0.8
|
|||
|
local height = font_size * 1.5
|
|||
|
local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
|
|||
|
local ax, by = title_ax + padding / 2, title_ay + height
|
|||
|
local bx = math.min(max_bx, title_ax + text_width_estimate(text, font_size) + padding * 2)
|
|||
|
ass:rect(ax, title_ay, bx, by, {
|
|||
|
color = options.background, opacity = visibility * options.top_bar_title_opacity, radius = 2,
|
|||
|
})
|
|||
|
ass:txt(ax + padding, title_ay + height / 2, 4, '{\\i1}' .. text .. '{\\i0}', {
|
|||
|
size = font_size, wrap = 2, color = options.foreground, border = 1, border_color = options.background,
|
|||
|
opacity = visibility * 0.8, clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by),
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ Controls ]]
|
|||
|
|
|||
|
-- `scale` - `options.controls_size` scale factor.
|
|||
|
-- `ratio` - Width/height ratio of a static or dynamic element.
|
|||
|
-- `ratio_min` Min ratio for 'dynamic' sized element.
|
|||
|
-- `skip` - Whether it should be skipped, determined during layout phase.
|
|||
|
---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table<string, boolean>}
|
|||
|
|
|||
|
---@class Controls : Element
|
|||
|
local Controls = class(Element)
|
|||
|
|
|||
|
function Controls:new() return Class.new(self) --[[@as Controls]] end
|
|||
|
function Controls:init()
|
|||
|
Element.init(self, 'controls')
|
|||
|
---@type ControlItem[] All control elements serialized from `options.controls`.
|
|||
|
self.controls = {}
|
|||
|
---@type ControlItem[] Only controls that match current dispositions.
|
|||
|
self.layout = {}
|
|||
|
|
|||
|
-- Serialize control elements
|
|||
|
local shorthands = {
|
|||
|
menu = 'command:menu:script-binding uosc/menu-blurred?Menu',
|
|||
|
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?Subtitles',
|
|||
|
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?Audio',
|
|||
|
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device',
|
|||
|
video = 'command:theaters:script-binding uosc/video#video>1?Video',
|
|||
|
playlist = 'command:list_alt:script-binding uosc/playlist?Playlist',
|
|||
|
chapters = 'command:bookmarks:script-binding uosc/chapters#chapters>0?Chapters',
|
|||
|
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?Stream quality',
|
|||
|
['open-file'] = 'command:file_open:script-binding uosc/open-file?Open file',
|
|||
|
['items'] = 'command:list_alt:script-binding uosc/items?Playlist/Files',
|
|||
|
prev = 'command:arrow_back_ios:script-binding uosc/prev?Previous',
|
|||
|
next = 'command:arrow_forward_ios:script-binding uosc/next?Next',
|
|||
|
first = 'command:first_page:script-binding uosc/first?First',
|
|||
|
last = 'command:last_page:script-binding uosc/last?Last',
|
|||
|
['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?Loop playlist',
|
|||
|
['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?Loop file',
|
|||
|
shuffle = 'toggle:shuffle:shuffle?Shuffle',
|
|||
|
fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen',
|
|||
|
}
|
|||
|
|
|||
|
-- Parse out disposition/config pairs
|
|||
|
local items = {}
|
|||
|
local in_disposition = false
|
|||
|
local current_item = nil
|
|||
|
for c in options.controls:gmatch('.') do
|
|||
|
if not current_item then current_item = {disposition = '', config = ''} end
|
|||
|
if c == '<' and #current_item.config == 0 then in_disposition = true
|
|||
|
elseif c == '>' and #current_item.config == 0 then in_disposition = false
|
|||
|
elseif c == ',' and not in_disposition then
|
|||
|
items[#items + 1] = current_item
|
|||
|
current_item = nil
|
|||
|
else
|
|||
|
local prop = in_disposition and 'disposition' or 'config'
|
|||
|
current_item[prop] = current_item[prop] .. c
|
|||
|
end
|
|||
|
end
|
|||
|
items[#items + 1] = current_item
|
|||
|
|
|||
|
-- Create controls
|
|||
|
self.controls = {}
|
|||
|
for i, item in ipairs(items) do
|
|||
|
local config = shorthands[item.config] and shorthands[item.config] or item.config
|
|||
|
local config_tooltip = split(config, ' *%? *')
|
|||
|
local tooltip = config_tooltip[2]
|
|||
|
config = shorthands[config_tooltip[1]]
|
|||
|
and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
|
|||
|
local config_badge = split(config, ' *# *')
|
|||
|
config = config_badge[1]
|
|||
|
local badge = config_badge[2]
|
|||
|
local parts = split(config, ' *: *')
|
|||
|
local kind, params = parts[1], itable_slice(parts, 2)
|
|||
|
|
|||
|
-- Serialize dispositions
|
|||
|
local dispositions = {}
|
|||
|
for _, definition in ipairs(split(item.disposition, ' *, *')) do
|
|||
|
if #definition > 0 then
|
|||
|
local value = definition:sub(1, 1) ~= '!'
|
|||
|
local name = not value and definition:sub(2) or definition
|
|||
|
local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
|
|||
|
dispositions[prop] = value
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Convert toggles into cycles
|
|||
|
if kind == 'toggle' then
|
|||
|
kind = 'cycle'
|
|||
|
params[#params + 1] = 'no/yes!'
|
|||
|
end
|
|||
|
|
|||
|
-- Create a control element
|
|||
|
local control = {dispositions = dispositions, kind = kind}
|
|||
|
|
|||
|
if kind == 'space' then
|
|||
|
control.sizing = 'space'
|
|||
|
elseif kind == 'gap' then
|
|||
|
table_assign(control, {sizing = 'dynamic', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
|
|||
|
elseif kind == 'command' then
|
|||
|
if #params ~= 2 then
|
|||
|
mp.error(string.format(
|
|||
|
'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
|
|||
|
))
|
|||
|
else
|
|||
|
local element = Button:new('control_' .. i, {
|
|||
|
icon = params[1],
|
|||
|
anchor_id = 'controls',
|
|||
|
on_click = function() mp.command(params[2]) end,
|
|||
|
tooltip = tooltip,
|
|||
|
count_prop = 'sub',
|
|||
|
})
|
|||
|
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
|||
|
if badge then self:register_badge_updater(badge, element) end
|
|||
|
end
|
|||
|
elseif kind == 'cycle' then
|
|||
|
if #params ~= 3 then
|
|||
|
mp.error(string.format(
|
|||
|
'cycle button needs 3 parameters, %d received: %s',
|
|||
|
#params, table.concat(params, '/')
|
|||
|
))
|
|||
|
else
|
|||
|
local state_configs = split(params[3], ' */ *')
|
|||
|
local states = {}
|
|||
|
|
|||
|
for _, state_config in ipairs(state_configs) do
|
|||
|
local active = false
|
|||
|
if state_config:sub(-1) == '!' then
|
|||
|
active = true
|
|||
|
state_config = state_config:sub(1, -2)
|
|||
|
end
|
|||
|
local state_params = split(state_config, ' *= *')
|
|||
|
local value, icon = state_params[1], state_params[2] or params[1]
|
|||
|
states[#states + 1] = {value = value, icon = icon, active = active}
|
|||
|
end
|
|||
|
|
|||
|
local element = CycleButton:new('control_' .. i, {
|
|||
|
prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip,
|
|||
|
})
|
|||
|
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
|||
|
if badge then self:register_badge_updater(badge, element) end
|
|||
|
end
|
|||
|
elseif kind == 'speed' then
|
|||
|
if not Elements.speed then
|
|||
|
local element = Speed:new({anchor_id = 'controls'})
|
|||
|
table_assign(control, {
|
|||
|
element = element, sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2,
|
|||
|
})
|
|||
|
else
|
|||
|
msg.error('there can only be 1 speed slider')
|
|||
|
end
|
|||
|
else
|
|||
|
msg.error('unknown element kind "' .. kind .. '"')
|
|||
|
break
|
|||
|
end
|
|||
|
|
|||
|
self.controls[#self.controls + 1] = control
|
|||
|
end
|
|||
|
|
|||
|
self:reflow()
|
|||
|
end
|
|||
|
|
|||
|
function Controls:reflow()
|
|||
|
-- Populate the layout only with items that match current disposition
|
|||
|
self.layout = {}
|
|||
|
for _, control in ipairs(self.controls) do
|
|||
|
local matches = true
|
|||
|
for prop, value in pairs(control.dispositions) do
|
|||
|
if state[prop] ~= value then
|
|||
|
matches = false
|
|||
|
break
|
|||
|
end
|
|||
|
end
|
|||
|
if control.element then control.element.enabled = matches end
|
|||
|
if matches then self.layout[#self.layout + 1] = control end
|
|||
|
end
|
|||
|
|
|||
|
self:update_dimensions()
|
|||
|
Elements:trigger('controls_reflow')
|
|||
|
end
|
|||
|
|
|||
|
---@param badge string
|
|||
|
---@param element Element An element that supports `badge` property.
|
|||
|
function Controls:register_badge_updater(badge, element)
|
|||
|
local prop_and_limit = split(badge, ' *> *')
|
|||
|
local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
|
|||
|
local observable_name, serializer = prop, nil
|
|||
|
|
|||
|
if itable_index_of({'sub', 'audio', 'video'}, prop) then
|
|||
|
observable_name = 'track-list'
|
|||
|
serializer = function(value)
|
|||
|
local count = 0
|
|||
|
for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
|
|||
|
return count
|
|||
|
end
|
|||
|
else
|
|||
|
serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
|
|||
|
end
|
|||
|
|
|||
|
local function handler(_, value)
|
|||
|
local new_value = serializer(value) --[[@as nil|string|integer]]
|
|||
|
local value_number = tonumber(new_value)
|
|||
|
if value_number then new_value = value_number > limit and value_number or nil end
|
|||
|
element.badge = new_value
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
mp.observe_property(observable_name, 'native', handler)
|
|||
|
end
|
|||
|
|
|||
|
function Controls:get_visibility()
|
|||
|
local timeline_is_hovered = Elements.timeline.enabled and Elements.timeline.proximity_raw == 0
|
|||
|
return (Elements.speed and Elements.speed.dragging) and 1 or timeline_is_hovered
|
|||
|
and -1 or Element.get_visibility(self)
|
|||
|
end
|
|||
|
|
|||
|
function Controls:update_dimensions()
|
|||
|
local window_border = Elements.window_border.size
|
|||
|
local size = state.fullormaxed and options.controls_size_fullscreen or options.controls_size
|
|||
|
local spacing = options.controls_spacing
|
|||
|
local margin = options.controls_margin
|
|||
|
|
|||
|
-- Container
|
|||
|
self.bx = display.width - window_border - margin
|
|||
|
self.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin
|
|||
|
self.ax, self.ay = window_border + margin, self.by - size
|
|||
|
|
|||
|
-- Re-enable all elements
|
|||
|
for c, control in ipairs(self.layout) do
|
|||
|
control.hide = false
|
|||
|
if control.element then control.element.enabled = true end
|
|||
|
end
|
|||
|
|
|||
|
-- Controls
|
|||
|
local available_width = self.bx - self.ax
|
|||
|
local statics_width = (#self.layout - 1) * spacing
|
|||
|
local min_content_width = statics_width
|
|||
|
local max_dynamics_width, dynamic_units, spaces = 0, 0, 0
|
|||
|
|
|||
|
-- Calculate statics_width, min_content_width, and count spaces
|
|||
|
for c, control in ipairs(self.layout) do
|
|||
|
if control.sizing == 'space' then
|
|||
|
spaces = spaces + 1
|
|||
|
elseif control.sizing == 'static' then
|
|||
|
local width = size * control.scale * control.ratio
|
|||
|
statics_width = statics_width + width
|
|||
|
min_content_width = min_content_width + width
|
|||
|
elseif control.sizing == 'dynamic' then
|
|||
|
min_content_width = min_content_width + size * control.scale * control.ratio_min
|
|||
|
max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
|
|||
|
dynamic_units = dynamic_units + control.scale * control.ratio
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Hide & disable elements in the middle until we fit into available width
|
|||
|
if min_content_width > available_width then
|
|||
|
local i = math.ceil(#self.layout / 2 + 0.1)
|
|||
|
for a = 0, #self.layout - 1, 1 do
|
|||
|
i = i + (a * (a % 2 == 0 and 1 or -1))
|
|||
|
local control = self.layout[i]
|
|||
|
|
|||
|
if control.kind ~= 'gap' and control.kind ~= 'space' then
|
|||
|
control.hide = true
|
|||
|
if control.element then control.element.enabled = false end
|
|||
|
if control.sizing == 'static' then
|
|||
|
local width = size * control.scale * control.ratio
|
|||
|
min_content_width = min_content_width - width - spacing
|
|||
|
statics_width = statics_width - width - spacing
|
|||
|
elseif control.sizing == 'dynamic' then
|
|||
|
min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
|
|||
|
max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
|
|||
|
dynamic_units = dynamic_units - control.scale * control.ratio
|
|||
|
end
|
|||
|
|
|||
|
if min_content_width < available_width then break end
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- Lay out the elements
|
|||
|
local current_x = self.ax
|
|||
|
local width_for_dynamics = available_width - statics_width
|
|||
|
local space_width = (width_for_dynamics - max_dynamics_width) / spaces
|
|||
|
|
|||
|
for c, control in ipairs(self.layout) do
|
|||
|
if not control.hide then
|
|||
|
local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
|
|||
|
local width, height = 0, 0
|
|||
|
|
|||
|
if sizing == 'space' then
|
|||
|
if space_width > 0 then width = space_width end
|
|||
|
elseif sizing == 'static' then
|
|||
|
height = size * scale
|
|||
|
width = height * ratio
|
|||
|
elseif sizing == 'dynamic' then
|
|||
|
height = size * scale
|
|||
|
width = max_dynamics_width < width_for_dynamics
|
|||
|
and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
|
|||
|
end
|
|||
|
|
|||
|
local bx = current_x + width
|
|||
|
if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
|
|||
|
current_x = bx + spacing
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
Elements:update_proximities()
|
|||
|
request_render()
|
|||
|
end
|
|||
|
|
|||
|
function Controls:on_dispositions() self:reflow() end
|
|||
|
function Controls:on_display() self:update_dimensions() end
|
|||
|
function Controls:on_prop_border() self:update_dimensions() end
|
|||
|
function Controls:on_prop_fullormaxed() self:update_dimensions() end
|
|||
|
|
|||
|
--[[ MuteButton ]]
|
|||
|
|
|||
|
---@class MuteButton : Element
|
|||
|
local MuteButton = class(Element)
|
|||
|
---@param props? ElementProps
|
|||
|
function MuteButton:new(props) return Class.new(self, 'volume_mute', props) --[[@as MuteButton]] end
|
|||
|
function MuteButton:on_mbtn_left_down() mp.commandv('cycle', 'mute') end
|
|||
|
function MuteButton:render()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
if visibility <= 0 then return end
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local icon_name = state.mute and 'volume_off' or 'volume_up'
|
|||
|
local width = self.bx - self.ax
|
|||
|
ass:icon(self.ax + (width / 2), self.by, width * 0.7, icon_name,
|
|||
|
{border = options.text_border, opacity = options.volume_opacity * visibility, align = 2}
|
|||
|
)
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ VolumeSlider ]]
|
|||
|
|
|||
|
---@class VolumeSlider : Element
|
|||
|
local VolumeSlider = class(Element)
|
|||
|
---@param props? ElementProps
|
|||
|
function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end
|
|||
|
function VolumeSlider:init(props)
|
|||
|
Element.init(self, 'volume_slider', props)
|
|||
|
self.pressed = false
|
|||
|
self.nudge_y = 0 -- vertical position where volume overflows 100
|
|||
|
self.nudge_size = 0
|
|||
|
self.draw_nudge = false
|
|||
|
self.spacing = 0
|
|||
|
self.radius = 1
|
|||
|
end
|
|||
|
|
|||
|
function VolumeSlider:set_volume(volume)
|
|||
|
volume = round(volume / options.volume_step) * options.volume_step
|
|||
|
if state.volume == volume then return end
|
|||
|
mp.commandv('set', 'volume', clamp(0, volume, state.volume_max))
|
|||
|
end
|
|||
|
|
|||
|
function VolumeSlider:set_from_cursor()
|
|||
|
local volume_fraction = (self.by - cursor.y - options.volume_border) / (self.by - self.ay - options.volume_border)
|
|||
|
self:set_volume(volume_fraction * state.volume_max)
|
|||
|
end
|
|||
|
|
|||
|
function VolumeSlider:on_coordinates()
|
|||
|
if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end
|
|||
|
local width = self.bx - self.ax
|
|||
|
self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max))
|
|||
|
self.nudge_size = round(width * 0.18)
|
|||
|
self.draw_nudge = self.ay < self.nudge_y
|
|||
|
self.spacing = round(width * 0.2)
|
|||
|
self.radius = math.max(2, (self.bx - self.ax) / 10)
|
|||
|
end
|
|||
|
function VolumeSlider:on_mbtn_left_down()
|
|||
|
self.pressed = true
|
|||
|
self:set_from_cursor()
|
|||
|
end
|
|||
|
function VolumeSlider:on_global_mbtn_left_up() self.pressed = false end
|
|||
|
function VolumeSlider:on_global_mouse_leave() self.pressed = false end
|
|||
|
function VolumeSlider:on_global_mouse_move()
|
|||
|
if self.pressed then self:set_from_cursor() end
|
|||
|
end
|
|||
|
function VolumeSlider:on_wheel_up() self:set_volume(state.volume + options.volume_step) end
|
|||
|
function VolumeSlider:on_wheel_down() self:set_volume(state.volume - options.volume_step) end
|
|||
|
|
|||
|
function VolumeSlider:render()
|
|||
|
local visibility = self:get_visibility()
|
|||
|
local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by
|
|||
|
local width, height = bx - ax, by - ay
|
|||
|
|
|||
|
if width <= 0 or height <= 0 or visibility <= 0 then return end
|
|||
|
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -infinity, self.nudge_size
|
|||
|
local volume_y = self.ay + options.volume_border +
|
|||
|
((height - (options.volume_border * 2)) * (1 - math.min(state.volume / state.volume_max, 1)))
|
|||
|
|
|||
|
-- Draws a rectangle with nudge at requested position
|
|||
|
---@param p number Padding from slider edges.
|
|||
|
---@param cy? number A y coordinate where to clip the path from the bottom.
|
|||
|
function create_nudged_path(p, cy)
|
|||
|
cy = cy or ay + p
|
|||
|
local ax, bx, by = ax + p, bx - p, by - p
|
|||
|
local r = math.max(1, self.radius - p)
|
|||
|
local d, rh = r * 2, r / 2
|
|||
|
local nudge_size = ((quarter_pi_sin * (nudge_size - p)) + p) / quarter_pi_sin
|
|||
|
local path = assdraw.ass_new()
|
|||
|
path:move_to(bx - r, by)
|
|||
|
path:line_to(ax + r, by)
|
|||
|
if cy > by - d then
|
|||
|
local subtracted_radius = (d - (cy - (by - d))) / 2
|
|||
|
local xbd = (r - subtracted_radius * 1.35) -- x bezier delta
|
|||
|
path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy)
|
|||
|
path:line_to(bx - r, cy)
|
|||
|
path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by)
|
|||
|
else
|
|||
|
path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r)
|
|||
|
local nudge_bottom_y = nudge_y + nudge_size
|
|||
|
|
|||
|
if cy + rh <= nudge_bottom_y then
|
|||
|
path:line_to(ax, nudge_bottom_y)
|
|||
|
if cy <= nudge_y then
|
|||
|
path:line_to((ax + nudge_size), nudge_y)
|
|||
|
local nudge_top_y = nudge_y - nudge_size
|
|||
|
if cy <= nudge_top_y then
|
|||
|
local r, rh = r, rh
|
|||
|
if cy > nudge_top_y - r then
|
|||
|
r = nudge_top_y - cy
|
|||
|
rh = r / 2
|
|||
|
end
|
|||
|
path:line_to(ax, nudge_top_y)
|
|||
|
path:line_to(ax, cy + r)
|
|||
|
path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
|
|||
|
path:line_to(bx - r, cy)
|
|||
|
path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
|
|||
|
path:line_to(bx, nudge_top_y)
|
|||
|
else
|
|||
|
local triangle_side = cy - nudge_top_y
|
|||
|
path:line_to((ax + triangle_side), cy)
|
|||
|
path:line_to((bx - triangle_side), cy)
|
|||
|
end
|
|||
|
path:line_to((bx - nudge_size), nudge_y)
|
|||
|
else
|
|||
|
local triangle_side = nudge_bottom_y - cy
|
|||
|
path:line_to((ax + triangle_side), cy)
|
|||
|
path:line_to((bx - triangle_side), cy)
|
|||
|
end
|
|||
|
path:line_to(bx, nudge_bottom_y)
|
|||
|
else
|
|||
|
path:line_to(ax, cy + r)
|
|||
|
path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
|
|||
|
path:line_to(bx - r, cy)
|
|||
|
path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
|
|||
|
end
|
|||
|
path:line_to(bx, by - r)
|
|||
|
path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by)
|
|||
|
end
|
|||
|
return path
|
|||
|
end
|
|||
|
|
|||
|
-- BG & FG paths
|
|||
|
local bg_path = create_nudged_path(0)
|
|||
|
local fg_path = create_nudged_path(options.volume_border, volume_y)
|
|||
|
|
|||
|
-- Background
|
|||
|
ass:new_event()
|
|||
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.background ..
|
|||
|
'\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}')
|
|||
|
ass:opacity(math.max(options.volume_opacity - 0.1, 0), visibility)
|
|||
|
ass:pos(0, 0)
|
|||
|
ass:draw_start()
|
|||
|
ass:append(bg_path.text)
|
|||
|
ass:draw_stop()
|
|||
|
|
|||
|
-- Foreground
|
|||
|
ass:new_event()
|
|||
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.foreground .. '}')
|
|||
|
ass:opacity(options.volume_opacity, visibility)
|
|||
|
ass:pos(0, 0)
|
|||
|
ass:draw_start()
|
|||
|
ass:append(fg_path.text)
|
|||
|
ass:draw_stop()
|
|||
|
|
|||
|
-- Current volume value
|
|||
|
local volume_string = tostring(round(state.volume * 10) / 10)
|
|||
|
local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale)
|
|||
|
if volume_y < self.by - self.spacing then
|
|||
|
ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
|
|||
|
size = font_size, color = options.foreground_text, opacity = visibility,
|
|||
|
clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
|
|||
|
})
|
|||
|
end
|
|||
|
if volume_y > self.by - self.spacing - font_size then
|
|||
|
ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
|
|||
|
size = font_size, color = options.background_text, opacity = visibility,
|
|||
|
clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
-- Disabled stripes for no audio
|
|||
|
if not state.has_audio then
|
|||
|
local fg_100_path = create_nudged_path(options.volume_border)
|
|||
|
local texture_opts = {
|
|||
|
size = 200, color = options.foreground, opacity = visibility * 0.1, anchor_x = ax,
|
|||
|
clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')',
|
|||
|
}
|
|||
|
ass:texture(ax, ay, bx, by, 'a', texture_opts)
|
|||
|
texture_opts.color = options.background
|
|||
|
texture_opts.anchor_x = ax + texture_opts.size / 28
|
|||
|
ass:texture(ax, ay, bx, by, 'a', texture_opts)
|
|||
|
end
|
|||
|
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ Volume ]]
|
|||
|
|
|||
|
---@class Volume : Element
|
|||
|
local Volume = class(Element)
|
|||
|
|
|||
|
function Volume:new() return Class.new(self) --[[@as Volume]] end
|
|||
|
function Volume:init()
|
|||
|
Element.init(self, 'volume')
|
|||
|
self.mute = MuteButton:new({anchor_id = 'volume'})
|
|||
|
self.slider = VolumeSlider:new({anchor_id = 'volume'})
|
|||
|
end
|
|||
|
|
|||
|
function Volume:get_visibility()
|
|||
|
return self.slider.pressed and 1 or Elements.timeline.proximity_raw == 0 and -1 or Element.get_visibility(self)
|
|||
|
end
|
|||
|
|
|||
|
function Volume:update_dimensions()
|
|||
|
local width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size
|
|||
|
local controls, timeline, top_bar = Elements.controls, Elements.timeline, Elements.top_bar
|
|||
|
local min_y = top_bar.enabled and top_bar.by or 0
|
|||
|
local max_y = (controls and controls.enabled and controls.ay) or (timeline.enabled and timeline.ay) or 0
|
|||
|
local available_height = max_y - min_y
|
|||
|
local max_height = available_height * 0.8
|
|||
|
local height = round(math.min(width * 8, max_height))
|
|||
|
self.enabled = height > width * 2 -- don't render if too small
|
|||
|
local margin = (width / 2) + Elements.window_border.size
|
|||
|
self.ax = round(options.volume == 'left' and margin or display.width - margin - width)
|
|||
|
self.ay = min_y + round((available_height - height) / 2)
|
|||
|
self.bx = round(self.ax + width)
|
|||
|
self.by = round(self.ay + height)
|
|||
|
self.mute:set_coordinates(self.ax, self.by - round(width * 0.8), self.bx, self.by)
|
|||
|
self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute.ay)
|
|||
|
end
|
|||
|
|
|||
|
function Volume:on_display() self:update_dimensions() end
|
|||
|
function Volume:on_prop_border() self:update_dimensions() end
|
|||
|
function Volume:on_controls_reflow() self:update_dimensions() end
|
|||
|
|
|||
|
--[[ Curtain ]]
|
|||
|
|
|||
|
---@class Curtain : Element
|
|||
|
local Curtain = class(Element)
|
|||
|
|
|||
|
function Curtain:new() return Class.new(self) --[[@as Curtain]] end
|
|||
|
function Curtain:init()
|
|||
|
Element.init(self, 'curtain', {ignores_menu = true})
|
|||
|
self.opacity = 0
|
|||
|
end
|
|||
|
|
|||
|
function Curtain:fadeout() self:tween_property('opacity', self.opacity, 0) end
|
|||
|
function Curtain:fadein() self:tween_property('opacity', self.opacity, 1) end
|
|||
|
|
|||
|
function Curtain:render()
|
|||
|
if self.opacity == 0 or options.curtain_opacity == 0 then return end
|
|||
|
local ass = assdraw.ass_new()
|
|||
|
ass:rect(0, 0, display.width, display.height, {
|
|||
|
color = '000000', opacity = options.curtain_opacity * self.opacity,
|
|||
|
})
|
|||
|
return ass
|
|||
|
end
|
|||
|
|
|||
|
--[[ CREATE STATIC ELEMENTS ]]
|
|||
|
|
|||
|
WindowBorder:new()
|
|||
|
PauseIndicator:new()
|
|||
|
Timeline:new()
|
|||
|
TopBar:new()
|
|||
|
if options.controls and options.controls ~= 'never' then Controls:new() end
|
|||
|
if itable_index_of({'left', 'right'}, options.volume) then Volume:new() end
|
|||
|
Curtain:new()
|
|||
|
|
|||
|
--[[ MENUS ]]
|
|||
|
|
|||
|
---@param data MenuData
|
|||
|
---@param opts? {submenu?: string; blurred?: boolean}
|
|||
|
function open_command_menu(data, opts)
|
|||
|
local menu = Menu:open(data, function(value)
|
|||
|
if type(value) == 'string' then
|
|||
|
mp.command(value)
|
|||
|
else
|
|||
|
---@diagnostic disable-next-line: deprecated
|
|||
|
mp.commandv((unpack or table.unpack)(value))
|
|||
|
end
|
|||
|
end, opts)
|
|||
|
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
|
|||
|
return menu
|
|||
|
end
|
|||
|
|
|||
|
---@param opts? {submenu?: string; blurred?: boolean}
|
|||
|
function toggle_menu_with_items(opts)
|
|||
|
if Menu:is_open('menu') then Menu:close()
|
|||
|
else open_command_menu({type = 'menu', items = config.menu_items}, opts) end
|
|||
|
end
|
|||
|
|
|||
|
---@param options {type: string; title: string; list_prop: string; list_serializer: fun(name: string, value: any): MenuDataItem[]; active_prop?: string; on_active_prop: fun(name: string, value: any, menu: Menu): integer; on_select: fun(value: any)}
|
|||
|
function create_self_updating_menu_opener(options)
|
|||
|
return function()
|
|||
|
if Menu:is_open(options.type) then Menu:close() return end
|
|||
|
local menu
|
|||
|
|
|||
|
-- Update active index and playlist content on playlist changes
|
|||
|
local ignore_initial_prop = true
|
|||
|
local function handle_list_prop_change(name, value)
|
|||
|
if ignore_initial_prop then ignore_initial_prop = false
|
|||
|
else menu:update_items(options.list_serializer(name, value)) end
|
|||
|
end
|
|||
|
|
|||
|
local ignore_initial_active = true
|
|||
|
local function handle_active_prop_change(name, value)
|
|||
|
if ignore_initial_active then ignore_initial_active = false
|
|||
|
else options.on_active_prop(name, value, menu) end
|
|||
|
end
|
|||
|
|
|||
|
local initial_items, selected_index = options.list_serializer(
|
|||
|
options.list_prop,
|
|||
|
mp.get_property_native(options.list_prop)
|
|||
|
)
|
|||
|
|
|||
|
-- Items and active_index are set in the handle_prop_change callback, since adding
|
|||
|
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
|||
|
menu = Menu:open(
|
|||
|
{type = options.type, title = options.title, items = initial_items, selected_index = selected_index},
|
|||
|
options.on_select, {
|
|||
|
on_open = function()
|
|||
|
mp.observe_property(options.list_prop, 'native', handle_list_prop_change)
|
|||
|
if options.active_prop then
|
|||
|
mp.observe_property(options.active_prop, 'native', handle_active_prop_change)
|
|||
|
end
|
|||
|
end,
|
|||
|
on_close = function()
|
|||
|
mp.unobserve_property(handle_list_prop_change)
|
|||
|
mp.unobserve_property(handle_active_prop_change)
|
|||
|
end,
|
|||
|
})
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command)
|
|||
|
local function serialize_tracklist(_, tracklist)
|
|||
|
local items = {}
|
|||
|
|
|||
|
if load_command then
|
|||
|
items[#items + 1] = {
|
|||
|
title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true,
|
|||
|
}
|
|||
|
end
|
|||
|
|
|||
|
local first_item_index = #items + 1
|
|||
|
local active_index = nil
|
|||
|
local disabled_item = nil
|
|||
|
|
|||
|
-- Add option to disable a subtitle track. This works for all tracks,
|
|||
|
-- but why would anyone want to disable audio or video? Better to not
|
|||
|
-- let people mistakenly select what is unwanted 99.999% of the time.
|
|||
|
-- If I'm mistaken and there is an active need for this, feel free to
|
|||
|
-- open an issue.
|
|||
|
if track_type == 'sub' then
|
|||
|
disabled_item = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true}
|
|||
|
items[#items + 1] = disabled_item
|
|||
|
end
|
|||
|
|
|||
|
for _, track in ipairs(tracklist) do
|
|||
|
if track.type == track_type then
|
|||
|
local hint_values = {
|
|||
|
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_values_filtered = {}
|
|||
|
for i = 1, #hint_values do
|
|||
|
if hint_values[i] then
|
|||
|
hint_values_filtered[#hint_values_filtered + 1] = hint_values[i]
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
items[#items + 1] = {
|
|||
|
title = (track.title and track.title or 'Track ' .. track.id),
|
|||
|
hint = table.concat(hint_values_filtered, ', '),
|
|||
|
value = track.id,
|
|||
|
active = track.selected,
|
|||
|
}
|
|||
|
|
|||
|
if track.selected then
|
|||
|
if disabled_item then disabled_item.active = false end
|
|||
|
active_index = #items
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
return items, active_index or first_item_index
|
|||
|
end
|
|||
|
|
|||
|
local function selection_handler(value)
|
|||
|
if value == '{load}' then
|
|||
|
mp.command(load_command)
|
|||
|
else
|
|||
|
mp.commandv('set', track_prop, value and value or 'no')
|
|||
|
|
|||
|
-- If subtitle track was selected, assume user also wants to see it
|
|||
|
if value and track_type == 'sub' then
|
|||
|
mp.commandv('set', 'sub-visibility', 'yes')
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
return create_self_updating_menu_opener({
|
|||
|
title = menu_title,
|
|||
|
type = track_type,
|
|||
|
list_prop = 'track-list',
|
|||
|
list_serializer = serialize_tracklist,
|
|||
|
on_select = selection_handler,
|
|||
|
})
|
|||
|
end
|
|||
|
|
|||
|
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
|
|||
|
|
|||
|
-- Opens a file navigation menu with items inside `directory_path`.
|
|||
|
---@param directory_path string
|
|||
|
---@param handle_select fun(path: string): nil
|
|||
|
---@param opts NavigationMenuOptions
|
|||
|
function open_file_navigation_menu(directory_path, handle_select, opts)
|
|||
|
directory = serialize_path(directory_path)
|
|||
|
opts = opts or {}
|
|||
|
|
|||
|
if not directory then
|
|||
|
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
local directories, dirs_error = utils.readdir(directory.path, 'dirs')
|
|||
|
local files, files_error = get_files_in_directory(directory.path, opts.allowed_types)
|
|||
|
local is_root = not directory.dirname
|
|||
|
|
|||
|
if not files or not directories then
|
|||
|
msg.error('Retrieving files from ' .. directory .. ' failed: ' .. (dirs_error or files_error or ''))
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
-- Files are already sorted
|
|||
|
table.sort(directories, file_order_comparator)
|
|||
|
|
|||
|
-- Pre-populate items with parent directory selector if not at root
|
|||
|
-- Each item value is a serialized path table it points to.
|
|||
|
local items = {}
|
|||
|
|
|||
|
if is_root then
|
|||
|
if state.os == 'windows' then
|
|||
|
items[#items + 1] = {
|
|||
|
title = '..', hint = 'Drives', value = {is_drives = true, is_to_parent = true}, separator = true,
|
|||
|
}
|
|||
|
end
|
|||
|
else
|
|||
|
local serialized = serialize_path(directory.dirname)
|
|||
|
serialized.is_directory = true;
|
|||
|
serialized.is_to_parent = true;
|
|||
|
items[#items + 1] = {title = '..', hint = 'parent dir', value = serialized, separator = true}
|
|||
|
end
|
|||
|
|
|||
|
local items_start_index = #items + 1
|
|||
|
|
|||
|
for _, dir in ipairs(directories) do
|
|||
|
local serialized = serialize_path(utils.join_path(directory.path, dir))
|
|||
|
if serialized then
|
|||
|
serialized.is_directory = true
|
|||
|
items[#items + 1] = {title = serialized.basename, value = serialized, hint = '/'}
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
for _, file in ipairs(files) do
|
|||
|
local serialized = serialize_path(utils.join_path(directory.path, file))
|
|||
|
if serialized then
|
|||
|
serialized.is_file = true
|
|||
|
items[#items + 1] = {title = serialized.basename, value = serialized}
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
for index, item in ipairs(items) do
|
|||
|
if not item.value.is_to_parent then
|
|||
|
if index == items_start_index then item.selected = true end
|
|||
|
|
|||
|
if opts.active_path == item.value.path then
|
|||
|
item.active = true
|
|||
|
if not opts.selected_path then item.selected = true end
|
|||
|
end
|
|||
|
|
|||
|
if opts.selected_path == item.value.path then item.selected = true end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
local menu_data = {
|
|||
|
type = opts.type, title = opts.title or directory.basename .. '/', items = items,
|
|||
|
on_open = opts.on_open, on_close = opts.on_close,
|
|||
|
}
|
|||
|
|
|||
|
return Menu:open(menu_data, function(path)
|
|||
|
local inheritable_options = {
|
|||
|
type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
|
|||
|
}
|
|||
|
|
|||
|
if path.is_drives then
|
|||
|
open_drives_menu(function(drive_path)
|
|||
|
open_file_navigation_menu(drive_path, handle_select, inheritable_options)
|
|||
|
end, {type = inheritable_options.type, title = inheritable_options.title, selected_path = directory.path})
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
if path.is_directory then
|
|||
|
-- Preselect directory we are coming from
|
|||
|
if path.is_to_parent then
|
|||
|
inheritable_options.selected_path = directory.path
|
|||
|
end
|
|||
|
|
|||
|
open_file_navigation_menu(path.path, handle_select, inheritable_options)
|
|||
|
else
|
|||
|
handle_select(path.path)
|
|||
|
end
|
|||
|
end)
|
|||
|
end
|
|||
|
|
|||
|
-- Opens a file navigation menu with Windows drives as items.
|
|||
|
---@param handle_select fun(path: string): nil
|
|||
|
---@param opts? NavigationMenuOptions
|
|||
|
function open_drives_menu(handle_select, opts)
|
|||
|
opts = opts or {}
|
|||
|
local process = mp.command_native({
|
|||
|
name = 'subprocess',
|
|||
|
capture_stdout = true,
|
|||
|
playback_only = false,
|
|||
|
args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
|
|||
|
})
|
|||
|
local items = {}
|
|||
|
|
|||
|
if process.status == 0 then
|
|||
|
for _, value in ipairs(split(process.stdout, '\n')) do
|
|||
|
local drive = string.match(value, 'Name=([A-Z]:)')
|
|||
|
if drive then
|
|||
|
local drive_path = normalize_path(drive)
|
|||
|
items[#items + 1] = {
|
|||
|
title = drive, hint = 'Drive', value = drive_path,
|
|||
|
selected = opts.selected_path == drive_path,
|
|||
|
active = opts.active_path == drive_path,
|
|||
|
}
|
|||
|
end
|
|||
|
end
|
|||
|
else
|
|||
|
msg.error(process.stderr)
|
|||
|
end
|
|||
|
|
|||
|
return Menu:open({type = opts.type, title = opts.title or 'Drives', items = items}, handle_select)
|
|||
|
end
|
|||
|
|
|||
|
-- EVENT HANDLERS
|
|||
|
|
|||
|
function create_state_setter(name, callback)
|
|||
|
return function(_, value)
|
|||
|
set_state(name, value)
|
|||
|
if callback then callback() end
|
|||
|
request_render()
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
function set_state(name, value)
|
|||
|
state[name] = value
|
|||
|
Elements:trigger('prop_' .. name, value)
|
|||
|
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 hardcode cursor position as infinity until
|
|||
|
-- we receive a first real mouse move event with coordinates other than 0,0.
|
|||
|
if not state.first_real_mouse_move_received then
|
|||
|
if cursor.x > 0 and cursor.y > 0 then
|
|||
|
state.first_real_mouse_move_received = true
|
|||
|
else
|
|||
|
cursor.x = infinity
|
|||
|
cursor.y = infinity
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
-- add 0.5 to be in the middle of the pixel
|
|||
|
cursor.x = (cursor.x + 0.5) / display.scale_x
|
|||
|
cursor.y = (cursor.y + 0.5) / display.scale_y
|
|||
|
|
|||
|
Elements: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_visibility', element:get_visibility(), 0, function()
|
|||
|
element.forced_visibility = nil
|
|||
|
end)
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
cursor.hidden = true
|
|||
|
Elements:update_proximities()
|
|||
|
Elements:trigger('global_mouse_leave')
|
|||
|
end
|
|||
|
|
|||
|
function handle_mouse_enter()
|
|||
|
cursor.hidden = false
|
|||
|
update_cursor_position()
|
|||
|
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:proximity_trigger('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 handle_file_end()
|
|||
|
local resume = false
|
|||
|
if not state.loop_file then
|
|||
|
if state.has_playlist then resume = state.shuffle and navigate_playlist(1)
|
|||
|
else resume = options.autoload and navigate_directory(1) end
|
|||
|
end
|
|||
|
-- Resume only when navigation happened
|
|||
|
if resume then mp.command('set pause no') end
|
|||
|
end
|
|||
|
local file_end_timer = mp.add_timeout(1, handle_file_end)
|
|||
|
file_end_timer:kill()
|
|||
|
|
|||
|
function load_file_index_in_current_directory(index)
|
|||
|
if not state.path or is_protocol(state.path) then return end
|
|||
|
|
|||
|
local serialized = serialize_path(state.path)
|
|||
|
if serialized and serialized.dirname then
|
|||
|
local files = get_files_in_directory(serialized.dirname, config.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(serialized.dirname, files[index]))
|
|||
|
end
|
|||
|
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
|
|||
|
|
|||
|
--[[ HOOKS]]
|
|||
|
|
|||
|
-- Mouse movement key binds
|
|||
|
local mouse_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()
|
|||
|
mouse_keybinds[#mouse_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(mouse_keybinds, 'mouse_movement', 'force')
|
|||
|
mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor')
|
|||
|
|
|||
|
mp.observe_property('osc', 'bool', function(name, value) if value == true then mp.set_property('osc', 'no') end end)
|
|||
|
function update_title(title_template)
|
|||
|
if title_template:sub(-6) == ' - mpv' then title_template = title_template:sub(1, -7) end
|
|||
|
set_state('title', mp.command_native({'expand-text', title_template}))
|
|||
|
end
|
|||
|
mp.register_event('file-loaded', function()
|
|||
|
set_state('path', normalize_path(mp.get_property_native('path')))
|
|||
|
update_title(mp.get_property_native('title'))
|
|||
|
end)
|
|||
|
mp.register_event('end-file', function(event)
|
|||
|
set_state('title', nil)
|
|||
|
if event.reason == 'eof' then
|
|||
|
file_end_timer:kill()
|
|||
|
handle_file_end()
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.observe_property('title', 'string', function(_, title)
|
|||
|
-- Don't change title if there is currently none
|
|||
|
if state.title then update_title(title) end
|
|||
|
end)
|
|||
|
mp.observe_property('playback-time', 'number', create_state_setter('time', function()
|
|||
|
-- Create a file-end event that triggers right before file ends
|
|||
|
file_end_timer:kill()
|
|||
|
if state.duration and state.time then
|
|||
|
local remaining = (state.duration - state.time) / state.speed
|
|||
|
if remaining < 5 then
|
|||
|
local timeout = remaining - 0.02
|
|||
|
if timeout > 0 then
|
|||
|
file_end_timer.timeout = timeout
|
|||
|
file_end_timer:resume()
|
|||
|
else handle_file_end() end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
update_human_times()
|
|||
|
|
|||
|
-- Select current chapter
|
|||
|
local current_chapter
|
|||
|
if state.time and state.chapters then
|
|||
|
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, true)
|
|||
|
end
|
|||
|
set_state('current_chapter', current_chapter)
|
|||
|
end))
|
|||
|
mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times))
|
|||
|
mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times))
|
|||
|
mp.observe_property('track-list', 'native', function(name, value)
|
|||
|
-- checks the file dispositions
|
|||
|
local is_image = false
|
|||
|
local types = {sub = 0, audio = 0, video = 0}
|
|||
|
for _, track in ipairs(value) do
|
|||
|
if track.type == 'video' then
|
|||
|
is_image = track.image
|
|||
|
if not is_image and not track.albumart then types.video = types.video + 1 end
|
|||
|
elseif types[track.type] then types[track.type] = types[track.type] + 1 end
|
|||
|
end
|
|||
|
set_state('is_audio', types.video == 0 and types.audio > 0)
|
|||
|
set_state('is_image', is_image)
|
|||
|
set_state('has_audio', types.audio > 0)
|
|||
|
set_state('has_many_audio', types.audio > 1)
|
|||
|
set_state('has_sub', types.sub > 0)
|
|||
|
set_state('has_many_sub', types.sub > 1)
|
|||
|
set_state('is_video', types.video > 0)
|
|||
|
set_state('has_many_video', types.video > 1)
|
|||
|
Elements:trigger('dispositions')
|
|||
|
end)
|
|||
|
mp.observe_property('chapter-list', 'native', function(_, chapters)
|
|||
|
local chapters, chapter_ranges = serialize_chapters(chapters), {}
|
|||
|
if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end
|
|||
|
set_state('chapters', chapters)
|
|||
|
set_state('chapter_ranges', chapter_ranges)
|
|||
|
set_state('has_chapter', #chapters > 0)
|
|||
|
Elements:trigger('dispositions')
|
|||
|
end)
|
|||
|
mp.observe_property('border', 'bool', create_state_setter('border'))
|
|||
|
mp.observe_property('loop-file', 'native', create_state_setter('loop_file'))
|
|||
|
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('playlist-pos-1', 'number', create_state_setter('playlist_pos'))
|
|||
|
mp.observe_property('playlist-count', 'number', function(_, value)
|
|||
|
set_state('playlist_count', value)
|
|||
|
set_state('has_playlist', value > 1)
|
|||
|
Elements:trigger('dispositions')
|
|||
|
end)
|
|||
|
mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed))
|
|||
|
mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed))
|
|||
|
mp.observe_property('idle-active', 'bool', create_state_setter('idle'))
|
|||
|
mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end))
|
|||
|
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', create_state_setter('hidpi_scale', update_display_dimensions))
|
|||
|
mp.observe_property('cache', 'native', create_state_setter('cache'))
|
|||
|
mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function()
|
|||
|
Elements:trigger('dispositions')
|
|||
|
end))
|
|||
|
mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
|||
|
local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil
|
|||
|
if cache_state then
|
|||
|
cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
|
|||
|
else cached_ranges = {} end
|
|||
|
|
|||
|
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
|
|||
|
(state.cache == 'auto' and state.is_stream))) then
|
|||
|
if state.uncached_ranges then set_state('uncached_ranges', nil) end
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
-- Normalize
|
|||
|
local ranges = {}
|
|||
|
for _, range in ipairs(cached_ranges) do
|
|||
|
ranges[#ranges + 1] = {
|
|||
|
math.max(range['start'] or 0, 0),
|
|||
|
math.min(range['end'] or state.duration, state.duration),
|
|||
|
}
|
|||
|
end
|
|||
|
table.sort(ranges, function(a, b) return a[1] < b[1] end)
|
|||
|
if bof then ranges[1][1] = 0 end
|
|||
|
if eof then ranges[#ranges][2] = state.duration end
|
|||
|
-- Invert cached ranges into uncached ranges, as that's what we're rendering
|
|||
|
local inverted_ranges = {{0, state.duration}}
|
|||
|
for _, cached in pairs(ranges) do
|
|||
|
inverted_ranges[#inverted_ranges][2] = cached[1]
|
|||
|
inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration}
|
|||
|
end
|
|||
|
uncached_ranges = {}
|
|||
|
local last_range = nil
|
|||
|
for _, range in ipairs(inverted_ranges) do
|
|||
|
if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges
|
|||
|
last_range[2] = range[2]
|
|||
|
else
|
|||
|
if range[2] - range[1] > 0.5 then -- skip short ranges
|
|||
|
uncached_ranges[#uncached_ranges + 1] = range
|
|||
|
last_range = range
|
|||
|
end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
set_state('uncached_ranges', uncached_ranges)
|
|||
|
end)
|
|||
|
mp.observe_property('display-fps', 'native', observe_display_fps)
|
|||
|
mp.observe_property('estimated-display-fps', 'native', update_render_delay)
|
|||
|
|
|||
|
-- KEY BINDABLE FEATURES
|
|||
|
|
|||
|
mp.add_key_binding(nil, 'toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end)
|
|||
|
mp.add_key_binding(nil, 'toggle-timeline', function() Elements:toggle({'timeline'}) end)
|
|||
|
mp.add_key_binding(nil, 'toggle-volume', function() Elements:toggle({'volume'}) end)
|
|||
|
mp.add_key_binding(nil, 'toggle-top-bar', function() Elements:toggle({'top_bar'}) 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)
|
|||
|
mp.add_key_binding(nil, 'menu', function() toggle_menu_with_items() end)
|
|||
|
mp.add_key_binding(nil, 'menu-blurred', function() toggle_menu_with_items({blurred = true}) end)
|
|||
|
local track_loaders = {
|
|||
|
{name = 'subtitles', prop = 'sub', allowed_types = config.subtitle_types},
|
|||
|
{name = 'audio', prop = 'audio', allowed_types = config.media_types},
|
|||
|
{name = 'video', prop = 'video', allowed_types = config.media_types},
|
|||
|
}
|
|||
|
for _, loader in ipairs(track_loaders) do
|
|||
|
local menu_type = 'load-' .. loader.name
|
|||
|
mp.add_key_binding(nil, menu_type, function()
|
|||
|
if Menu:is_open(menu_type) then Menu:close() return end
|
|||
|
|
|||
|
local path = state.path
|
|||
|
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 = get_default_directory()
|
|||
|
end
|
|||
|
open_file_navigation_menu(
|
|||
|
path,
|
|||
|
function(path) mp.commandv(loader.prop .. '-add', path) end,
|
|||
|
{type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types}
|
|||
|
)
|
|||
|
end)
|
|||
|
end
|
|||
|
mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener(
|
|||
|
'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles'
|
|||
|
))
|
|||
|
mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener(
|
|||
|
'Audio', 'audio', 'aid', 'script-binding uosc/load-audio'
|
|||
|
))
|
|||
|
mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener(
|
|||
|
'Video', 'video', 'vid', 'script-binding uosc/load-video'
|
|||
|
))
|
|||
|
mp.add_key_binding(nil, 'playlist', create_self_updating_menu_opener({
|
|||
|
title = 'Playlist',
|
|||
|
type = 'playlist',
|
|||
|
list_prop = 'playlist',
|
|||
|
list_serializer = 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),
|
|||
|
active = item.current,
|
|||
|
value = index,
|
|||
|
}
|
|||
|
end
|
|||
|
return items
|
|||
|
end,
|
|||
|
on_select = 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_serializer = function(_, chapters)
|
|||
|
local items = {}
|
|||
|
chapters = normalize_chapters(chapters)
|
|||
|
for _, chapter in ipairs(chapters) do
|
|||
|
local item = {
|
|||
|
title = chapter.title or '',
|
|||
|
hint = mp.format_time(chapter.time),
|
|||
|
value = chapter.time,
|
|||
|
}
|
|||
|
items[#items + 1] = item
|
|||
|
end
|
|||
|
if not state.time then return items end
|
|||
|
for index = #items, 1, -1 do
|
|||
|
if state.time >= items[index].value then
|
|||
|
items[index].active = true
|
|||
|
break
|
|||
|
end
|
|||
|
end
|
|||
|
return items
|
|||
|
end,
|
|||
|
active_prop = 'playback-time',
|
|||
|
on_active_prop = function(_, playback_time, menu)
|
|||
|
-- Select first chapter from the end with time lower
|
|||
|
-- than current playing position.
|
|||
|
local position = playback_time
|
|||
|
if not position then
|
|||
|
menu:deactivate_items()
|
|||
|
return
|
|||
|
end
|
|||
|
local items = menu.current.items
|
|||
|
for index = #items, 1, -1 do
|
|||
|
if position >= items[index].value then
|
|||
|
menu:activate_unique_index(index)
|
|||
|
return
|
|||
|
end
|
|||
|
end
|
|||
|
end,
|
|||
|
on_select = function(time) mp.commandv('seek', tostring(time), 'absolute') end,
|
|||
|
}))
|
|||
|
mp.add_key_binding(nil, 'show-in-directory', function()
|
|||
|
-- Ignore URLs
|
|||
|
if not state.path or is_protocol(state.path) then return end
|
|||
|
|
|||
|
if state.os == 'windows' then
|
|||
|
utils.subprocess_detached({args = {'explorer', '/select,', state.path}, cancellable = false})
|
|||
|
elseif state.os == 'macos' then
|
|||
|
utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false})
|
|||
|
elseif state.os == 'linux' then
|
|||
|
local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false})
|
|||
|
|
|||
|
-- Fallback opens the folder with xdg-open instead
|
|||
|
if result.status ~= 0 then
|
|||
|
utils.subprocess({args = {'xdg-open', serialize_path(state.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 items = {}
|
|||
|
|
|||
|
for _, height in ipairs(config.stream_quality_options) do
|
|||
|
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
|
|||
|
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
|
|||
|
end
|
|||
|
|
|||
|
Menu:open({type = 'stream-quality', title = 'Stream quality', items = items}, 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-recorded 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' position.
|
|||
|
-- 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)
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'open-file', function()
|
|||
|
if Menu:is_open('open-file') then Menu:close() return end
|
|||
|
|
|||
|
local directory
|
|||
|
local active_file
|
|||
|
|
|||
|
if state.path == nil or is_protocol(state.path) then
|
|||
|
local serialized = serialize_path(get_default_directory())
|
|||
|
if serialized then
|
|||
|
directory = serialized.path
|
|||
|
active_file = nil
|
|||
|
end
|
|||
|
else
|
|||
|
local serialized = serialize_path(state.path)
|
|||
|
if serialized then
|
|||
|
directory = serialized.dirname
|
|||
|
active_file = serialized.path
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
if not directory then
|
|||
|
msg.error('Couldn\'t serialize path "' .. state.path .. '".')
|
|||
|
return
|
|||
|
end
|
|||
|
|
|||
|
-- Update active file in directory navigation menu
|
|||
|
local function handle_file_loaded()
|
|||
|
if Menu:is_open('open-file') then
|
|||
|
Elements.menu:activate_value(normalize_path(mp.get_property_native('path')))
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
open_file_navigation_menu(
|
|||
|
directory,
|
|||
|
function(path) mp.commandv('loadfile', path) end,
|
|||
|
{
|
|||
|
type = 'open-file',
|
|||
|
allowed_types = config.media_types,
|
|||
|
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, 'shuffle', function() set_state('shuffle', not state.shuffle) end)
|
|||
|
mp.add_key_binding(nil, 'items', function()
|
|||
|
if state.has_playlist then
|
|||
|
mp.command('script-binding uosc/playlist')
|
|||
|
else
|
|||
|
mp.command('script-binding uosc/open-file')
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'next', function() navigate_item(1) end)
|
|||
|
mp.add_key_binding(nil, 'prev', function() navigate_item(-1) end)
|
|||
|
mp.add_key_binding(nil, 'next-file', function() navigate_directory(1) end)
|
|||
|
mp.add_key_binding(nil, 'prev-file', function() navigate_directory(-1) end)
|
|||
|
mp.add_key_binding(nil, 'first', function()
|
|||
|
if state.has_playlist then
|
|||
|
mp.commandv('set', 'playlist-pos-1', '1')
|
|||
|
else
|
|||
|
load_file_index_in_current_directory(1)
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'last', function()
|
|||
|
if state.has_playlist then
|
|||
|
mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count))
|
|||
|
else
|
|||
|
load_file_index_in_current_directory(-1)
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'first-file', function() load_file_index_in_current_directory(1) end)
|
|||
|
mp.add_key_binding(nil, 'last-file', function() load_file_index_in_current_directory(-1) end)
|
|||
|
mp.add_key_binding(nil, 'delete-file-next', function()
|
|||
|
local next_file = nil
|
|||
|
local is_local_file = state.path and not is_protocol(state.path)
|
|||
|
|
|||
|
if is_local_file then
|
|||
|
if Menu:is_open('open-file') then Elements.menu:delete_value(state.path) end
|
|||
|
end
|
|||
|
|
|||
|
if state.has_playlist then
|
|||
|
mp.commandv('playlist-remove', 'current')
|
|||
|
else
|
|||
|
if is_local_file then
|
|||
|
local paths, current_index = get_adjacent_paths(state.path, config.media_types)
|
|||
|
if paths and current_index then
|
|||
|
local index, path = decide_navigation_in_list(paths, current_index, 1)
|
|||
|
if path then next_file = path end
|
|||
|
end
|
|||
|
end
|
|||
|
|
|||
|
if next_file then mp.commandv('loadfile', next_file)
|
|||
|
else mp.commandv('stop') end
|
|||
|
end
|
|||
|
|
|||
|
if is_local_file then delete_file(state.path) end
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'delete-file-quit', function()
|
|||
|
mp.command('stop')
|
|||
|
if state.path and not is_protocol(state.path) then delete_file(state.path) end
|
|||
|
mp.command('quit')
|
|||
|
end)
|
|||
|
mp.add_key_binding(nil, 'audio-device', create_self_updating_menu_opener({
|
|||
|
title = 'Audio devices',
|
|||
|
type = 'audio-device-list',
|
|||
|
list_prop = 'audio-device-list',
|
|||
|
list_serializer = function(_, audio_device_list)
|
|||
|
local current_device = mp.get_property('audio-device') or 'auto'
|
|||
|
local ao = mp.get_property('current-ao') or ''
|
|||
|
local items = {}
|
|||
|
local active_index = nil
|
|||
|
for _, device in ipairs(audio_device_list) do
|
|||
|
if device.name == 'auto' or string.match(device.name, '^' .. ao) then
|
|||
|
local hint = string.match(device.name, ao .. '/(.+)')
|
|||
|
if not hint then hint = device.name end
|
|||
|
items[#items + 1] = {
|
|||
|
title = device.description,
|
|||
|
hint = hint,
|
|||
|
value = device.name,
|
|||
|
}
|
|||
|
if device.name == current_device then active_index = #items end
|
|||
|
end
|
|||
|
end
|
|||
|
return items, active_index
|
|||
|
end,
|
|||
|
on_select = function(name) mp.commandv('set', 'audio-device', name) 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)
|
|||
|
|
|||
|
-- MESSAGE HANDLERS
|
|||
|
|
|||
|
mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end)
|
|||
|
mp.register_script_message('get-version', function(script)
|
|||
|
mp.commandv('script-message-to', script, 'uosc-version', config.version)
|
|||
|
end)
|
|||
|
mp.register_script_message('open-menu', function(json, submenu_id)
|
|||
|
local data = utils.parse_json(json)
|
|||
|
if type(data) ~= 'table' or type(data.items) ~= 'table' then
|
|||
|
msg.error('open-menu: received json didn\'t produce a table with menu configuration')
|
|||
|
else
|
|||
|
if data.type and Menu:is_open(data.type) then Menu:close()
|
|||
|
else open_command_menu(data, {submenu_id = submenu_id}) end
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.register_script_message('update-menu', function(json)
|
|||
|
local data = utils.parse_json(json)
|
|||
|
if type(data) ~= 'table' or type(data.items) ~= 'table' then
|
|||
|
msg.error('update-menu: received json didn\'t produce a table with menu configuration')
|
|||
|
else
|
|||
|
local menu = data.type and Menu:is_open(data.type)
|
|||
|
if menu then menu:update(data)
|
|||
|
else open_command_menu(data) end
|
|||
|
end
|
|||
|
end)
|
|||
|
mp.register_script_message('thumbfast-info', function(json)
|
|||
|
local data = utils.parse_json(json)
|
|||
|
if type(data) ~= 'table' or not data.width or not data.height then
|
|||
|
thumbnail.disabled = true
|
|||
|
msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information')
|
|||
|
else
|
|||
|
thumbnail = data
|
|||
|
end
|
|||
|
end)
|