diff --git a/README.md b/README.md index 936c0a2..6950180 100644 --- a/README.md +++ b/README.md @@ -367,45 +367,50 @@ Parameters ID (title) of the submenu, including `>` subsections as defined in `input.conf`. It has to be match the title exactly. -### `show-menu ` +### `open-menu [submenu_id]` -A message other scripts can send to display a uosc menu serialized as JSON. +A message other scripts can send to open a uosc menu serialized as JSON. You can optionally pass a `submenu_id` to pre-open a submenu. The ID is the submenu title chain leading to the submenu concatenated with `>`, for example `Tools > Aspect ratio`. Menu data structure: ``` Menu { type?: string; - title?: string; - selected_index?: number; - active_index?: number; - items: Item[]; -} - -Submenu { title?: string; items: Item[]; + keep_open?: boolean; } Item = Command | Submenu; +Submenu { + title?: string; + hint?: string; + items: Item[]; + keep_open?: boolean; +} + Command { title?: string; hint?: string; + icon?: string; value: string | string[]; bold?: boolean; italic?: boolean; muted?: boolean; + selected?: number; + active?: number; + keep_open?: boolean; } ``` When command value is a string, it'll be passed to `mp.command(value)`. If it's a table (array) of strings, it'll be used as `mp.commandv(table.unpack(value))`. -Menu `type` controls what happens when opening a menu when some other menu is already open. When the new menu type is different, it'll replace the currently opened menu. When it's the same, the currently open menu will simply be closed. This is used to implement toggling (open->close) of menus with the same key. +Menu `type` controls what happens when opening a menu when some other menu is already open. When the new menu type is different, it'll replace the currently opened menu. When it's the same, the currently open menu will simply be closed. This is used to implement toggling of menus with the same type. -`active_index` displays the item at that index as active. For example, in subtitles menu, the currently displayed subtitles are considered _active_. +When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child. Set to `false` to override the parent. -`selected_index` marks item at that index as selected - the starting position for all keyboard based navigation in the menu. It defaults to `active_index` if any, or `1` otherwise, which means in most cases you can just ignore this prop. +It's usually not necessary to define `selected` as it'll default to `active` item, or 1st item in the list. Example: @@ -414,11 +419,9 @@ local utils = require('mp.utils') local menu = { type = 'menu_type', title = 'Custom menu', - active_index = 1, - selected_index = 1, items = { {title = 'Foo', hint = 'foo', value = 'quit'}, - {title = 'Bar', hint = 'bar', value = 'quit'}, + {title = 'Bar', hint = 'bar', value = 'quit', active = true}, } } local json = utils.format_json(menu) diff --git a/script-opts/uosc.conf b/script-opts/uosc.conf index ded00a6..d6955f3 100644 --- a/script-opts/uosc.conf +++ b/script-opts/uosc.conf @@ -107,7 +107,7 @@ menu_min_width=260 menu_min_width_fullscreen=360 menu_wasd_navigation=no menu_hjkl_navigation=no -menu_opacity=0.8 +menu_opacity=1 menu_parent_opacity=0.4 menu_font_scale=1 diff --git a/scripts/uosc.lua b/scripts/uosc.lua index 71924ba..0459f3c 100644 --- a/scripts/uosc.lua +++ b/scripts/uosc.lua @@ -15,168 +15,21 @@ local msg = require('mp.msg') local osd = mp.create_osd_overlay('ass-events') local infinity = 1e309 --- OPTIONS/CONFIG/STATE -local options = { - timeline_style = 'line', - timeline_line_width = 2, - timeline_line_width_fullscreen = 3, - timeline_line_width_minimized_scale = 10, - timeline_size_min = 2, - timeline_size_max = 40, - timeline_size_min_fullscreen = 0, - timeline_size_max_fullscreen = 60, - timeline_start_hidden = false, - timeline_persistency = 'paused', - timeline_opacity = 0.9, - timeline_border = 1, - timeline_step = 5, - timeline_cached_ranges = '4e845c:0.5', - timeline_font_scale = 1, - timeline_chapters = 'dots', - timeline_chapters_opacity = 0.2, - timeline_chapters_width = 6, - - controls = 'menu,gap,subtitles,audio,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 = 60, - volume_persistency = '', - volume_opacity = 0.8, - volume_border = 1, - volume_step = 1, - volume_font_scale = 1, - - speed_persistency = '', - speed_opacity = 1, - speed_step = 0.1, - speed_step_is_factor = false, - speed_font_scale = 1, - - menu_item_height = 36, - menu_item_height_fullscreen = 50, - menu_min_width = 260, - menu_min_width_fullscreen = 360, - menu_wasd_navigation = false, - menu_hjkl_navigation = false, - menu_opacity = 0.8, - menu_parent_opacity = 0.4, - menu_font_scale = 1, - - top_bar = 'no-border', - top_bar_size = 40, - top_bar_size_fullscreen = 46, - top_bar_persistency = '', - top_bar_controls = true, - top_bar_title = true, - - window_border_size = 1, - window_border_opacity = 0.8, - - ui_scale = 1, - pause_on_click_shorter_than = 0, - flash_duration = 1000, - proximity_in = 40, - proximity_out = 120, - color_foreground = 'ffffff', - color_foreground_text = '000000', - color_background = '000000', - color_background_text = 'ffffff', - total_time = false, - time_precision = 0, - font_bold = false, - autohide = false, - pause_indicator = 'flash', - curtain_opacity = 0.5, - stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144', - directory_navigation_loops = false, - media_types = '3gp,asf,avi,avif,bmp,flac,flv,gif,h264,h265,jpeg,jpg,jxl,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv', - subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt', - font_height_to_letter_width_ratio = 0.5, - default_directory = '~/', - chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end', -} -opt.read_options(options, 'uosc') -local config = { - -- sets max rendering frequency in case the - -- native rendering frequency could not be detected - render_delay = 1 / 60, - font = mp.get_property('options/osd-font'), -} -local display = { - width = 1280, - height = 720, - aspect = 1.77778, -} -local cursor = { - hidden = true, -- true when autohidden or outside of the player window - x = 0, - y = 0, -} -local state = { - version = uosc_version, - 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 - media_title = '', - 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 = nil, - chapter_ranges = nil, - border = mp.get_property_native('border'), - fullscreen = mp.get_property_native('fullscreen'), - maximized = mp.get_property_native('window-maximized'), - fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'), - render_timer = nil, - render_last_time = 0, - volume = nil, - volume_max = nil, - mute = nil, - is_video = nil, - is_audio = nil, -- true if file is audio only (mp3, etc) - is_image = nil, - is_stream = nil, - has_audio = nil, - cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function() - if not options.autohide then return end - handle_mouse_leave() - end), - mouse_bindings_enabled = false, - cached_ranges = nil, - render_delay = config.render_delay, - first_real_mouse_move_received = false, - playlist_count = 0, - playlist_pos = 0, - margin_top = 0, - margin_bottom = 0, -} -local forced_key_bindings -- defined at the bottom next to events - --- HELPERS +--[[ BASE HELPERS ]] +---@param number number function round(number) local modulus = number % 1 return modulus < 0.5 and math.floor(number) or math.ceil(number) end -function call_me_maybe(fn, value1, value2, value3) - if fn then fn(value1, value2, value3) end +function call_me_maybe(fn, ...) + if fn then fn(...) end end +---@param str string +---@param pattern string +---@return string[] function split(str, pattern) local list = {} local full_pattern = '(.-)' .. pattern @@ -248,12 +101,421 @@ function itable_slice(itable, start_pos, end_pos) return new_table end -function table_copy(table) - local new_table = {} - for key, value in pairs(table) do new_table[key] = value end - return new_table +---@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_copy(table) + local result = {} + for key, value in pairs(table) do result[key] = type(value) == 'table' and table_copy(value) or value end + return result +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 options = { + timeline_style = 'line', + timeline_line_width = 2, + timeline_line_width_fullscreen = 3, + timeline_line_width_minimized_scale = 10, + timeline_size_min = 2, + timeline_size_max = 40, + timeline_size_min_fullscreen = 0, + timeline_size_max_fullscreen = 60, + timeline_start_hidden = false, + timeline_persistency = 'paused', + timeline_opacity = 0.9, + timeline_border = 1, + timeline_step = 5, + timeline_cached_ranges = '4e845c:0.8', + timeline_font_scale = 1, + timeline_chapters = 'dots', + timeline_chapters_opacity = 0.2, + timeline_chapters_width = 6, + + controls = 'menu,gap,subtitles,audio,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 = 60, + volume_persistency = '', + volume_opacity = 0.8, + volume_border = 1, + volume_step = 1, + volume_font_scale = 1, + + speed_persistency = '', + speed_opacity = 1, + speed_step = 0.1, + speed_step_is_factor = false, + speed_font_scale = 1, + + menu_item_height = 36, + menu_item_height_fullscreen = 50, + menu_min_width = 260, + menu_min_width_fullscreen = 360, + menu_wasd_navigation = false, + menu_hjkl_navigation = false, + menu_opacity = 1, + menu_parent_opacity = 0.4, + menu_font_scale = 1, + + top_bar = 'no-border', + top_bar_size = 40, + top_bar_size_fullscreen = 46, + top_bar_persistency = '', + top_bar_controls = true, + top_bar_title = true, + + window_border_size = 1, + window_border_opacity = 0.8, + + ui_scale = 1, + pause_on_click_shorter_than = 0, + flash_duration = 1000, + proximity_in = 40, + proximity_out = 120, + color_foreground = 'ffffff', + color_foreground_text = '000000', + color_background = '000000', + color_background_text = 'ffffff', + total_time = false, + time_precision = 0, + font_bold = false, + autohide = false, + pause_indicator = 'flash', + curtain_opacity = 0.5, + stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144', + directory_navigation_loops = false, + media_types = '3gp,asf,avi,avif,bmp,flac,flv,gif,h264,h265,jpeg,jpg,jxl,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv', + subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt', + font_height_to_letter_width_ratio = 0.5, + default_directory = '~/', + chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end', +} +opt.read_options(options, 'uosc') +-- Normalize values +options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) +options.timeline_chapters = itable_index_of({'dots', 'lines', 'lines-top', 'lines-bottom'}, options.timeline_chapters) + and options.timeline_chapters or 'never' + +--[[ 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, ' *, *'), + cached_ranges = (function() + if options.timeline_cached_ranges == '' or options.timeline_cached_ranges == 'no' then return nil end + local parts = split(options.timeline_cached_ranges, ':') + return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil + end)(), + menu_items = (function() + local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'}) + local input_conf_meta, meta_error = utils.file_info(input_conf_path) + + -- File doesn't exist + if not input_conf_meta or not input_conf_meta.is_file then return end + + local main_menu = {items = {}, items_by_command = {}} + local 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)(), +} +-- 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, + aspect = 1.77778, +} +local cursor = { + hidden = true, -- true when autohidden or outside of the player window + x = 0, + y = 0, +} +local state = { + os = (function() + if os.getenv('windir') ~= nil then return 'windows' end + local homedir = os.getenv('HOME') + if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'macos' end + return 'linux' + end)(), + cwd = mp.get_property('working-directory'), + path = nil, -- current file path or URL + media_title = '', + 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 = nil, + border = mp.get_property_native('border'), + fullscreen = mp.get_property_native('fullscreen'), + maximized = mp.get_property_native('window-maximized'), + fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'), + render_timer = nil, + render_last_time = 0, + volume = nil, + volume_max = nil, + mute = nil, + is_video = nil, + is_audio = nil, -- true if file is audio only (mp3, etc) + is_image = nil, + is_stream = nil, + has_audio = nil, + cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function() + if not options.autohide then return end + handle_mouse_leave() + end), + mouse_bindings_enabled = false, + cached_ranges = nil, + render_delay = config.render_delay, + first_real_mouse_move_received = false, + playlist_count = 0, + playlist_pos = 0, + margin_top = 0, + margin_bottom = 0, +} + +-- Parse `chapter_ranges` option into workable data structure +local chapter_ranges = nil +for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do + local start_patterns, color, opacity, end_patterns = string.match( + definition, '([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)' + ) + + -- Valid definition + if start_patterns then + start_patterns = start_patterns:lower() + end_patterns = end_patterns:lower() + local uses_bof = start_patterns:find('{bof}') ~= nil + local uses_eof = end_patterns:find('{eof}') ~= nil + local chapter_range = { + start_patterns = split(start_patterns, '|'), + end_patterns = split(end_patterns, '|'), + color = color, + opacity = tonumber(opacity), + ranges = {}, + } + + -- Filter out special keywords so we don't use them when matching titles + if uses_bof then + chapter_range.start_patterns = itable_remove(chapter_range.start_patterns, '{bof}') + end + if uses_eof and chapter_range.end_patterns then + chapter_range.end_patterns = itable_remove(chapter_range.end_patterns, '{eof}') + end + + chapter_range['serialize'] = function(chapters) + chapter_range.ranges = {} + local current_range = nil + -- bof and eof should be used only once per timeline + -- eof is only used when last range is missing end + local bof_used = false + + local function start_range(chapter) + -- If there is already a range started, should we append or overwrite? + -- I chose overwrite here. + current_range = {['start'] = chapter} + end + + local function end_range(chapter) + current_range['end'] = chapter + chapter_range.ranges[#chapter_range.ranges + 1] = current_range + -- Mark both chapter objects + current_range['start']._uosc_used_as_range_point = true + current_range['end']._uosc_used_as_range_point = true + -- Clear for next range + current_range = nil + end + + for _, chapter in ipairs(chapters) do + if type(chapter.title) == 'string' then + local lowercase_title = chapter.title:lower() + + -- Is ending check and handling + if chapter_range.end_patterns then + chapter.is_end_only = false + for _, end_pattern in ipairs(chapter_range.end_patterns) do + if lowercase_title:find(end_pattern) then + if current_range == nil and uses_bof and not bof_used then + bof_used = true + start_range({time = 0}) + end + if current_range ~= nil then + end_range(chapter) + end + chapter.is_end_only = end_pattern ~= '.*' + break + end + end + end + + -- Is start check and handling + for _, start_pattern in ipairs(chapter_range.start_patterns) do + if lowercase_title:find(start_pattern) then + start_range(chapter) + chapter.is_end_only = false + break + end + end + end + end + + -- If there is an unfinished range and range type accepts eof, use it + if current_range ~= nil and uses_eof then + end_range({time = state.duration or infinity}) + end + end + + chapter_ranges = chapter_ranges or {} + chapter_ranges[#chapter_ranges + 1] = chapter_range + end +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 + +--[[ HELPERS ]] + -- Sorting comparator close to (but not exactly) how file explorers sort files local word_order_comparator = (function() local symbol_order @@ -275,6 +537,9 @@ local word_order_comparator = (function() default_order = 21 end + ---@param a string|number + ---@param b string|number + ---@return boolean return function(a, b) a = a:lower() b = b:lower() @@ -295,90 +560,70 @@ local word_order_comparator = (function() end)() -- Creates in-between frames to animate value from `from` to `to` numbers. --- Returns function that terminates animation. --- `to` can be a function that returns target value, useful for movable targets. --- `speed` is an optional float between 1-instant and 0-infinite duration --- `callback` is called either on animation end, or when animation is canceled -function tween(from, to, setter, speed, callback) - if type(speed) ~= 'number' then - callback = speed - speed = 0.3 - end +---@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 timeout - local getTo = type(to) == 'function' and to or function() return to end - local cutoff = math.abs(getTo() - from) * 0.01 + 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() + call_me_maybe(callback) + end + end local function tick() - from = from + ((getTo() - from) * speed) - local is_end = math.abs(getTo() - from) <= cutoff - setter(is_end and getTo() or from) + 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 - call_me_maybe(callback) - else - timeout:resume() - end + if is_end then finish() + else timeout:resume() end end - timeout = mp.add_timeout(0.016, tick) + timeout = mp.add_timeout(state.render_delay, tick) tick() - return function() - timeout:kill() - call_me_maybe(callback) - end -end - --- Kills ongoing animation if one is already running on this element. --- Killed animation will not get its `on_end` called. -function tween_element(element, from, to, setter, speed, callback) - if type(speed) ~= 'number' then - callback = speed - speed = 0.3 - end - - tween_element_stop(element) - - element.stop_current_animation = tween( - from, to, - function(value) setter(element, value) end, - speed, - function() - element.stop_current_animation = nil - call_me_maybe(callback, element) - end - ) -end - --- Stopped animation will not get its on_end called. -function tween_element_is_tweening(element) - return element and element.stop_current_animation -end - --- Stopped animation will not get its on_end called. -function tween_element_stop(element) - call_me_maybe(element and element.stop_current_animation) -end - --- Helper to automatically use an element property setter -function tween_element_property(element, prop, from, to, speed, callback) - tween_element(element, from, to, function(_, value) element[prop] = value end, speed, callback) + 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 + 1) local dy = math.max(rect.ay - point.y, 0, point.y - rect.by + 1) 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(text) * font_size * options.font_height_to_letter_width_ratio +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(text) do - text_length = text_length + length - end - return text_length * font_size * options.font_height_to_letter_width_ratio + for _, _, length in utf8_iter(text) do text_length = text_length + length end + return text_length end function utf8_iter(string) @@ -468,6 +713,7 @@ function wrap_text(text, target_line_length) 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 @@ -495,11 +741,13 @@ function format_time(seconds) 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 @@ -528,20 +776,25 @@ function normalize_path(path) 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-_]+://') 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 @@ -566,6 +819,9 @@ function serialize_path(path) } 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') @@ -587,6 +843,10 @@ function get_files_in_directory(directory, allowed_types) return files end +---@param file_path string +---@param direction 'forward'|'backward' +---@param allowed_types? string[] +---@return nil|string function get_adjacent_file(file_path, direction, allowed_types) local current_file = serialize_path(file_path) if not current_file then return end @@ -616,8 +876,9 @@ end -- Can't use `os.remove()` as it fails on paths with unicode characters. -- Returns `result, error`, result is table of: -- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean` -function delete_file(file_path) - local args = state.os == 'windows' and {'cmd', '/C', 'del', file_path} or {'rm', file_path} +---@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, @@ -642,7 +903,28 @@ function get_normalized_chapters() return chapters end --- ASSDRAW EXTENSIONS +function parse_chapters() + -- Sometimes state.duration is not initialized yet for some reason + state.duration = mp.get_property_native('duration') + local chapters = get_normalized_chapters() + + if not chapters or not state.duration then return end + + -- Reset custom ranges + for _, chapter_range in ipairs(state.chapter_ranges or {}) do + chapter_range.serialize(chapters) + end + + for _, chapter in ipairs(chapters) do + chapter.title_wrapped, chapter.title_wrapped_width = wrap_text(chapter.title, 25) + chapter.title_wrapped = ass_escape(chapter.title_wrapped) + end + + state.chapters = chapters + request_render() +end + +--[[ ASSDRAW EXTENSIONS ]] local ass_mt = getmetatable(assdraw.ass_new()) @@ -677,7 +959,7 @@ function ass_mt:icon(x, y, size, name, opts) self:txt(x, y, opts.align or 5, name, opts) end --- String +-- Text -- Named `txt` because `ass.text` is a value. ---@param x number ---@param y number @@ -748,7 +1030,7 @@ end ---@param ay number ---@param bx number ---@param by number ----@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string, radius?: number} +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; border_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 @@ -762,7 +1044,11 @@ function ass_mt:rect(ax, ay, bx, by, opts) end -- opacity if opts.opacity then - tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) + tags = tags .. string.format('\\1a&H%X&', opacity_to_alpha(opts.opacity)) + end + -- border opacity + if opts.border_opacity then + tags = tags .. string.format('\\3a&H%X&', opacity_to_alpha(opts.border_opacity)) end -- clip if opts.clip then @@ -791,10 +1077,11 @@ function ass_mt:circle(x, y, radius, opts) self:rect(x - radius, y - radius, x + radius, y + radius, opts) end --- ELEMENTS +--[[ 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') @@ -805,6 +1092,7 @@ function Elements:add(element) self.itable[#self.itable + 1] = element self[element.id] = element + request_render() end @@ -813,681 +1101,23 @@ function Elements:remove(idOrElement) local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement local element = Elements[id] if element then - element:destroy() + 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:trigger(name, ...) - for _, element in self:ipairs() do element:trigger(name, ...) end -end - -function Elements:has(id) return self[id] ~= nil end - -function Elements:ipairs() return ipairs(self.itable) end - --- Element ---[[ -Signature: -{ - -- element rectangle coordinates - ax = 0, ay = 0, bx = 0, by = 0, - -- cursor<->element relative proximity as a 0-1 floating number - -- where 0 = completely away, and 1 = touching/hovering - -- so it's easy to work with and throw into equations - proximity = 0, - -- raw cursor<->element proximity in pixels - proximity_raw = infinity, - -- called when element is created - ?init = function(this), - -- called manually when disposing of element - ?destroy = function(this), - -- triggered when event happens and cursor is above element - ?on_{event_name} = function(this), - -- triggered when any event happens anywhere on a page - ?on_global_{event_name} = function(this), - -- object - ?render = function(this_element), -} -]] -local Element = { - ax = 0, ay = 0, bx = 0, by = 0, - proximity = 0, proximity_raw = infinity, -} -Element.__index = Element - --- Element object props used by proximity, events, renderer, or other parts ot the system: --- `id`: `string` REQUIRED - every element has to have a unique ID --- `enabled`: `boolean` - determines element's visibility and interactivity (mouse, keyboard). element still receives --- `on_prop_{name}` and other environment events --- `ignores_menu` - don't hide when menu is open --- `ax,ay,bx,by`: `number` - element's coordinates used to determine proximity, element is responsible for setting these --- `proximity`: `number` - element's proximity to the cursor. set by system before mouse_move is fired --- fraction between `0` = away, `1` = touching/above --- `proximity_raw`: `number` - element's raw proximity to the cursor in pixels. set by system before mouse_move event --- `anchor_id` - id of an element from which this one should inherit proximity via max(this.proximity, anchor.proximity) --- `on_{event_name}`: `function` (optional) - binds a listener to `{event_name}` when defined. Example events: --- `on_mbt_left_down`, `on_prop_has_audio` ----@param id string ----@param props {enabled: boolean} -function Element.new(id, props) - props.id = id - local element = setmetatable(props, Element) - element._eventListeners = {} - - -- Flash timer - element._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function() - local getTo = function() return element.proximity end - element:tween_property('forced_visibility', 1, getTo, function() - element.forced_visibility = nil - end) - end) - element._flash_out_timer:kill() - - element:init() - - return element -end - -function Element:init() end - -function Element:destroy() 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 = options[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 proximity - if self.forced_visibility then return self.forced_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 self.forced_visibility or (anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility)) -end - --- Call method if it exists -function Element:maybe(name, ...) - if self[name] then return self[name](self, ...) end -end - --- Tween helpers -function Element:tween(...) tween_element(self, ...) end - -function Element:tween_property(...) tween_element_property(self, ...) end - -function Element:tween_stop() tween_element_stop(self) end - -function Element:is_tweening() tween_element_is_tweening(self) end - --- Event listeners -function Element:on(name, handler) - if self._eventListeners[name] == nil then self._eventListeners[name] = {} end - local preexistingIndex = itable_index_of(self._eventListeners[name], handler) - if preexistingIndex then - return - else - self._eventListeners[name][#self._eventListeners[name] + 1] = handler - end -end - -function Element:off(name, handler) - if self._eventListeners[name] == nil then return end - local index = itable_index_of(self._eventListeners, handler) - if index then table.remove(self._eventListeners, index) end -end - -function Element:trigger(name, ...) - self:maybe('on_' .. name, ...) - if self._eventListeners[name] == nil then return end - for _, handler in ipairs(self._eventListeners[name]) do handler(...) end - request_render() -end - --- Briefly flashes the element for `options.flash_duration` milliseconds. --- Useful to visualize changes of volume and timeline when changed via hotkeys. --- Implemented by briefly adding animated `forced_visibility` property to the element. -function Element:flash() - if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then - self:tween_stop() - self.forced_visibility = 1 - self._flash_out_timer:kill() - self._flash_out_timer:resume() - end -end - --- MENU ---[[ -Usage: -``` -local items = { - {title = 'Foo title', hint = 'Ctrl+F', value = 'foo'}, - {title = 'Bar title', hint = 'Ctrl+B', value = 'bar'}, - { - title = 'Submenu', - items = { - {title = 'Sub item 1', value = 'sub1'}, - {title = 'Sub item 2', value = 'sub2'} - } - } -} - -function open_item(value) - value -- value from `item.value` -end - -menu:open(items, open_item) -``` -]] -local Menu = {} -Menu.__index = Menu -local menu = setmetatable({key_bindings = {}, is_closing = false}, Menu) - -function Menu:is_open(menu_type) - return Elements.menu ~= nil and (not menu_type or Elements.menu.type == menu_type) -end - ----@alias MenuItem {title?: string, hint?: string, value: any} ----@alias MenuOptions {type?: string; title?: string, active_index?: number, selected_index?: number, on_open?: fun(), on_close?: fun(), parent_menu?: any} - ----@param items MenuItem[] ----@param open_item fun(value: any) ----@param opts? MenuOptions -function Menu:open(items, open_item, opts) - opts = opts or {} - - if menu:is_open() then - if not opts.parent_menu then - menu:close(true, function() - menu:open(items, open_item, opts) - end) - return - end - else - menu:enable_key_bindings() - Elements.curtain:fadein() - end - - Elements:add(Element.new('menu', { - enabled = true, - ignores_menu = true, - type = nil, -- menu type such as `menu`, `chapters`, ... - title = nil, - estimated_max_width = nil, - width = nil, - height = nil, - offset_x = 0, -- used to animated from/to left when submenu - item_height = nil, - item_spacing = 1, - item_content_spacing = nil, - font_size = nil, - font_size_hint = nil, - scroll_step = nil, -- item height + item spacing - scroll_height = nil, -- items + spacings - container height - scroll_y = 0, - opacity = 0, - relative_parent_opacity = 0.4, - items = items, - active_index = nil, - selected_index = nil, - open_item = open_item, - parent_menu = nil, - init = function(this) - -- Already initialized - if this.width ~= nil then return end - - -- Apply options - for key, value in pairs(opts) do this[key] = value end - - if not this.selected_index then - this.selected_index = this.active_index - end - - -- Set initial dimensions - this:update_content_dimensions() - this:on_display_change() - - -- Scroll to selected item - this:scroll_to_item(this.selected_index) - - -- Transition in animation - menu.transition = {to = 'child', target = this} - local start_offset = this.parent_menu and (this.parent_menu.width + this.width) / 2 or 0 - - tween_element(menu.transition.target, 0, 1, function(_, pos) - this:set_offset_x(round(start_offset * (1 - pos))) - this.opacity = pos - this:set_parent_opacity(1 - ((1 - options.menu_parent_opacity) * pos)) - end, function() - menu.transition = nil - update_proximities() - end) - end, - update_content_dimensions = function(this) - this.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height - this.font_size = round(this.item_height * 0.48 * options.menu_font_scale) - this.font_size_hint = this.font_size - 1 - this.item_content_spacing = round((this.item_height - this.font_size) * 0.6) - this.scroll_step = this.item_height + this.item_spacing - - -- Estimate width of a widest item - local estimated_max_width = 0 - for _, item in ipairs(this.items) do - local spacings_in_item = item.hint and 3 or 2 - local has_submenu = item.items ~= nil - -- M as a stand in for icon - local hint_icon = item.hint or (has_submenu and 'M' or nil) - local hint_icon_size = item.hint and this.font_size_hint or this.font_size - item.title_width = text_width_estimate(item.title, this.font_size) - item.hint_width = text_width_estimate(hint_icon, hint_icon_size) - local estimated_width = item.title_width + item.hint_width - + (this.item_content_spacing * spacings_in_item) - if estimated_width > estimated_max_width then - estimated_max_width = estimated_width - end - end - - -- Also check menu title - local menu_title = this.title and this.title or '' - local estimated_menu_title_width = text_width_estimate(menu_title, this.font_size) - if estimated_menu_title_width > estimated_max_width then - estimated_max_width = estimated_menu_title_width - end - - this.estimated_max_width = estimated_max_width - - if this.parent_menu then - this.parent_menu:update_content_dimensions() - end - end, - update_dimensions = function(this) - -- Coordinates and sizes are of the scrollable area to make - -- consuming values in rendering easier. Title drawn above this, so - -- we need to account for that in max_height and ay position. - local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width - this.width = round(math.min(math.max(this.estimated_max_width, min_width), display.width * 0.9)) - local title_height = this.title and this.scroll_step or 0 - local max_height = round(display.height * 0.9) - title_height - this.height = math.min(round(this.scroll_step * #this.items) - this.item_spacing, max_height) - this.scroll_height = math.max((this.scroll_step * #this.items) - this.height - this.item_spacing, 0) - this:scroll_to(this.scroll_y) -- re-applies scroll limits - end, - on_display_change = function(this) - this:update_dimensions() - - local title_height = this.title and this.scroll_step or 0 - this.ax = round((display.width - this.width) / 2) + this.offset_x - this.ay = round((display.height - this.height) / 2 + (title_height / 2)) - this.bx = round(this.ax + this.width) - this.by = round(this.ay + this.height) - - if this.parent_menu then - this.parent_menu:on_display_change() - end - - -- Update offsets for new sizes - -- needs to be called after the widths of the parents has been updated - this:set_offset_x(this.offset_x) - end, - on_prop_fullormaxed = function(this) - this:update_content_dimensions() - this:on_display_change() - end, - update = function(this, props) - if props then - for key, value in pairs(props) do this[key] = value end - end - - -- Trigger changes and re-render - this:update_content_dimensions() - this:on_display_change() - - -- Reset indexes and scroll - this:select_index(this.selected_index or this.active_index or (this.items and #this.items > 0 and 1 or nil)) - this:activate_index(this.active_index) - this:scroll_to(this.scroll_y) - request_render() - end, - set_offset_x = function(this, offset) - local delta = offset - this.offset_x - this.offset_x = offset - this.ax = this.ax + delta - this.bx = this.bx + delta - if this.parent_menu then - this.parent_menu:set_offset_x(offset - ((this.width + this.parent_menu.width) / 2) - this.item_spacing) - else - update_proximities() - end - end, - fadeout = function(this, callback) - this:tween(1, 0, function(this, pos) - this.opacity = pos - this:set_parent_opacity(pos * options.menu_parent_opacity) - end, callback) - end, - set_parent_opacity = function(this, opacity) - if this.parent_menu then - this.parent_menu.opacity = opacity - this.parent_menu:set_parent_opacity(opacity * options.menu_parent_opacity) - end - end, - get_item_index_below_cursor = function(this) - if #this.items < 1 then return nil end - return math.max(1, math.min(math.ceil((cursor.y - this.ay + this.scroll_y) / this.scroll_step), #this.items)) - end, - scroll_to = function(this, pos) - this.scroll_y = math.max(math.min(pos, this.scroll_height), 0) - request_render() - end, - scroll_to_item = function(this, index) - if (index and index >= 1 and index <= #this.items) then - this:scroll_to(round((this.scroll_step * (index - 1)) - ((this.height - this.scroll_step) / 2))) - end - end, - select_index = function(this, index) - this.selected_index = (index and index >= 1 and index <= #this.items) and index or nil - request_render() - end, - select_value = function(this, value) - this:select_index(itable_find(this.items, function(_, item) return item.value == value end)) - end, - activate_index = function(this, index) - this.active_index = (index and index >= 1 and index <= #this.items) and index or nil - if not this.selected_index then - this.selected_index = this.active_index - this:scroll_to_item(this.selected_index) - end - request_render() - end, - activate_value = function(this, value) - this:activate_index(itable_find(this.items, function(_, item) return item.value == value end)) - end, - delete_index = function(this, index) - if (index and index >= 1 and index <= #this.items) then - local previous_active_value = this.active_index and this.items[this.active_index].value or nil - table.remove(this.items, index) - this:update_content_dimensions() - this:on_display_change() - if previous_active_value then this:activate_value(previous_active_value) end - this:scroll_to_item(this.selected_index) - end - end, - delete_value = function(this, value) - this:delete_index(itable_find(this.items, function(_, item) return item.value == value end)) - end, - prev = function(this) - this.selected_index = math.max(this.selected_index and this.selected_index - 1 or #this.items, 1) - this:scroll_to_item(this.selected_index) - end, - next = function(this) - this.selected_index = math.min(this.selected_index and this.selected_index + 1 or 1, #this.items) - this:scroll_to_item(this.selected_index) - end, - back = function(this) - if menu.transition then - local transition_target = menu.transition.target - local transition_target_type = menu.transition.target - tween_element_stop(transition_target) - if transition_target_type == 'parent' then - Elements:add(transition_target) - end - menu.transition = nil - if transition_target then transition_target:back() end - return - else - menu.transition = {to = 'parent', target = this.parent_menu} - end - - if menu.transition.target == nil then - menu:close() - return - end - - local target = menu.transition.target - local to_offset = -target.offset_x + this.offset_x - - tween_element(target, 0, 1, function(_, pos) - this:set_offset_x(round(to_offset * pos)) - this.opacity = 1 - pos - this:set_parent_opacity(options.menu_parent_opacity + ((1 - options.menu_parent_opacity) * pos)) - end, function() - menu.transition = nil - Elements:add(target) - update_proximities() - end) - end, - open_selected_item = function(this, soft) - -- If there is a transition active and this method got called, it - -- means we are animating from this menu to parent menu, and all - -- calls to this method should be relayed to the parent menu. - if menu.transition and menu.transition.to == 'parent' then - local target = menu.transition.target - tween_element_stop(target) - menu.transition = nil - if target then target:open_selected_item(soft) end - return - end - - if this.selected_index then - local item = this.items[this.selected_index] - -- Is submenu - if item.items then - menu:open(item.items, this.open_item, { - type = this.type, - parent_menu = this, - selected_index = #item.items > 0 and 1 or nil, - }) - else - if soft ~= true then menu:close(true) end - this.open_item(item.value) - end - end - end, - open_selected_item_soft = function(this) this:open_selected_item(true) end, - close = function(this) menu:close() end, - on_global_mbtn_left_down = function(this) - if this.proximity_raw == 0 then - this.selected_index = this:get_item_index_below_cursor() - this:open_selected_item() - else - -- check if this is clicking on any parent menus - local parent_menu = this.parent_menu - repeat - if parent_menu then - if get_point_to_rectangle_proximity(cursor, parent_menu) == 0 then - this:back() - return - end - parent_menu = parent_menu.parent_menu - end - until parent_menu == nil - - menu:close() - end - end, - on_global_mouse_move = function(this) - if this.proximity_raw == 0 then - this.selected_index = this:get_item_index_below_cursor() - else - if this.selected_index then this.selected_index = nil end - end - request_render() - end, - on_wheel_up = function(this) - this.selected_index = nil - this:scroll_to(this.scroll_y - this.scroll_step) - this:on_global_mouse_move() -- selects item below cursor - request_render() - end, - on_wheel_down = function(this) - this.selected_index = nil - this:scroll_to(this.scroll_y + this.scroll_step) - this:on_global_mouse_move() -- selects item below cursor - request_render() - end, - on_pgup = function(this) - local items_per_page = round((this.height / this.scroll_step) * 0.4) - local paged_index = (this.selected_index and this.selected_index or #this.items) - items_per_page - this.selected_index = math.min(math.max(1, paged_index), #this.items) - if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end - end, - on_pgdwn = function(this) - local items_per_page = round((this.height / this.scroll_step) * 0.4) - local paged_index = (this.selected_index and this.selected_index or 1) + items_per_page - this.selected_index = math.min(math.max(1, paged_index), #this.items) - if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end - end, - on_home = function(this) - this.selected_index = math.min(1, #this.items) - if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end - end, - on_end = function(this) - this.selected_index = #this.items - if this.selected_index > 0 then this:scroll_to_item(this.selected_index) end - end, - render = render_menu, - })) - - Elements.menu:maybe('on_open') -end - -function Menu:add_key_binding(key, name, fn, flags) - menu.key_bindings[#menu.key_bindings + 1] = name - mp.add_forced_key_binding(key, name, fn, flags) -end - -function Menu:enable_key_bindings() - menu.key_bindings = {} - -- The `mp.set_key_bindings()` method would be easier here, but that - -- doesn't support 'repeatable' flag, so we are stuck with this monster. - menu:add_key_binding('up', 'menu-prev1', self:create_action('prev'), 'repeatable') - menu:add_key_binding('down', 'menu-next1', self:create_action('next'), 'repeatable') - menu:add_key_binding('left', 'menu-back1', self:create_action('back')) - menu:add_key_binding('right', 'menu-select1', self:create_action('open_selected_item')) - menu:add_key_binding('shift+right', 'menu-select-soft1', self:create_action('open_selected_item_soft')) - menu:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_action('open_selected_item_soft')) - - if options.menu_wasd_navigation then - menu:add_key_binding('w', 'menu-prev2', self:create_action('prev'), 'repeatable') - menu:add_key_binding('a', 'menu-back2', self:create_action('back')) - menu:add_key_binding('s', 'menu-next2', self:create_action('next'), 'repeatable') - menu:add_key_binding('d', 'menu-select2', self:create_action('open_selected_item')) - menu:add_key_binding('shift+d', 'menu-select-soft2', self:create_action('open_selected_item_soft')) - end - - if options.menu_hjkl_navigation then - menu:add_key_binding('h', 'menu-back3', self:create_action('back')) - menu:add_key_binding('j', 'menu-next3', self:create_action('next'), 'repeatable') - menu:add_key_binding('k', 'menu-prev3', self:create_action('prev'), 'repeatable') - menu:add_key_binding('l', 'menu-select3', self:create_action('open_selected_item')) - menu:add_key_binding('shift+l', 'menu-select-soft3', self:create_action('open_selected_item_soft')) - end - - menu:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_action('back')) - menu:add_key_binding('bs', 'menu-back-alt4', self:create_action('back')) - menu:add_key_binding('enter', 'menu-select-alt3', self:create_action('open_selected_item')) - menu:add_key_binding('kp_enter', 'menu-select-alt4', self:create_action('open_selected_item')) - menu:add_key_binding('esc', 'menu-close', self:create_action('close')) - menu:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup')) - menu:add_key_binding('pgdwn', 'menu-page-down', self:create_action('on_pgdwn')) - menu:add_key_binding('home', 'menu-home', self:create_action('on_home')) - menu:add_key_binding('end', 'menu-end', self:create_action('on_end')) -end - -function Menu:disable_key_bindings() - for _, name in ipairs(menu.key_bindings) do mp.remove_key_binding(name) end - menu.key_bindings = {} -end - -function Menu:create_action(name) - return function(...) - if Elements.menu then Elements.menu:maybe(name, ...) end - end -end - -function Menu:close(immediate, callback) - if type(immediate) ~= 'boolean' then callback = immediate end - - if Elements:has('menu') and not menu.is_closing then - local function close() - local current_menu = Elements.menu - while current_menu do - current_menu:maybe('on_close') - current_menu = current_menu.parent_menu - end - Elements:remove('menu') - menu.is_closing = false - update_proximities() - menu:disable_key_bindings() - call_me_maybe(callback) - request_render() - end - - menu.is_closing = true - Elements.curtain:fadeout() - - if immediate then - close() - else - Elements.menu:fadeout(close) - end - end -end - --- STATE UPDATES - -function update_display_dimensions() - local dpi_scale = mp.get_property_native('display-hidpi-scale', 1.0) - dpi_scale = dpi_scale * options.ui_scale - - local width, height, aspect = mp.get_osd_size() - display.width = width / dpi_scale - display.height = height / dpi_scale - display.aspect = aspect - - -- Tell elements about this - Elements:trigger('display_change') - - -- Some elements probably changed their rectangles as a reaction to `display_change` - update_proximities() - request_render() -end - -function update_element_cursor_proximity(element) - if cursor.hidden then - element.proximity_raw = infinity - element.proximity = 0 - else - local range = options.proximity_out - options.proximity_in - element.proximity_raw = get_point_to_rectangle_proximity(cursor, element) - element.proximity = 1 - (math.min(math.max(element.proximity_raw - options.proximity_in, 0), range) / range) - end -end - -function update_proximities() +function Elements:update_proximities() local capture_mbtn_left = false local capture_wheel = false - local menu_only = menu:is_open() + local menu_only = Elements.menu ~= nil local mouse_leave_elements = {} local mouse_enter_elements = {} -- Calculates proximities and opacities for defined elements - for _, element in Elements:ipairs() do + for _, element in self:ipairs() do if element.enabled then local previous_proximity_raw = element.proximity_raw @@ -1496,13 +1126,13 @@ function update_proximities() if element.ignores_menu then capture_mbtn_left = true capture_wheel = true - update_element_cursor_proximity(element) + element:update_proximity() else element.proximity_raw = infinity element.proximity = 0 end else - update_element_cursor_proximity(element) + element:update_proximity() end -- Element has global forced key listeners @@ -1527,23 +1157,73 @@ function update_proximities() end end - -- Enable key group captures elements request. - if capture_mbtn_left then - forced_key_bindings.mbtn_left:enable() - else - forced_key_bindings.mbtn_left:disable() - end - if capture_wheel then - forced_key_bindings.wheel:enable() - else - forced_key_bindings.wheel:disable() - end + -- 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 +---@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 dpi_scale = mp.get_property_native('display-hidpi-scale', 1.0) + dpi_scale = dpi_scale * options.ui_scale + + local width, height, aspect = mp.get_osd_size() + display.width = width / dpi_scale + display.height = height / dpi_scale + display.aspect = aspect + + -- Tell elements about this + Elements:trigger('display') + + -- 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() @@ -1566,13 +1246,12 @@ function update_human_times() 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 top, bottom = 0, 0 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 - bottom = (display.height - bottom_y) / display.height + 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 @@ -1586,710 +1265,7 @@ function update_margins() utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom)) end --- ELEMENT RENDERERS - -function render_timeline(this) - if this.size_max == 0 then return end - - local size_min = this:get_effective_size_min() - local size = this:get_effective_size() - - if size < 1 then return end - - local ass = assdraw.ass_new() - - -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min - local hide_text_below = math.max(this.font_size * 0.7, size_min * 2) - local hide_text_ramp = hide_text_below / 2 - local text_opacity = math.max(math.min(size - hide_text_below, hide_text_ramp), 0) / hide_text_ramp - - local spacing = math.max(math.floor((this.size_max - this.font_size) / 2.5), 4) - local progress = state.time / state.duration - local is_line = options.timeline_style == 'line' - - -- Foreground & Background bar coordinates - local bax, bay, bbx, bby = this.ax, this.by - size - this.top_border, this.bx, this.by - local fax, fay, fbx, fby = 0, bay + this.top_border, 0, bby - - -- Controls the padding of time on the timeline due to line width. - -- It's a distance from the center of the line to its edge when at the - -- start or end of the timeline. Effectively half of the line width. - local time_padding = 0 - - if is_line then - local minimized_fraction = 1 - (size - size_min) / (this.size_max - size_min) - local width_normal = this:get_effective_line_width() - local max_min_width_delta = size_min > 0 - and width_normal - width_normal * options.timeline_line_width_minimized_scale - or 0 - local line_width = width_normal - (max_min_width_delta * minimized_fraction) - local current_time_x = (bbx - bax - line_width) * progress - fax = current_time_x + bax - fbx = fax + line_width - if line_width > 2 then time_padding = round(line_width / 2) end - else - fax = bax - fbx = bax + this.width * progress - end - - local time_x = bax + time_padding - local time_width = this.width - time_padding * 2 - local foreground_size = fby - fay - local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping - - -- Background - ass:new_event() - ass:pos(0, 0) - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_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 - local function render_progress() - ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity}) - end - - -- Custom ranges - local function render_ranges() - if state.chapter_ranges ~= nil then - for i, chapter_range in ipairs(state.chapter_ranges) do - for i, range in ipairs(chapter_range.ranges) do - local rax = time_x + time_width * (range['start'].time / state.duration) - local rbx = time_x + time_width * (range['end'].time / state.duration) - -- for 1px chapter size, use the whole size of the bar including padding - local ray = size <= 1 and bay or fay - local rby = size <= 1 and bby or fby - ass:rect(rax, ray, rbx, rby, {color = chapter_range.color, opacity = chapter_range.opacity}) - end - end - end - end - - -- Chapters - local function render_chapters() - if ( - options.timeline_chapters == 'never' - or ( - (state.chapters == nil or #state.chapters == 0) - and state.ab_loop_a == nil - and state.ab_loop_b == nil - ) - ) then return end - - local dots = false - -- Defaults are for `lines` - local chapter_width = options.timeline_chapters_width - local chapter_height, chapter_y - if options.timeline_chapters == 'dots' then - dots = true - chapter_height = math.min(chapter_width, (foreground_size / 2) + 1) - chapter_y = fay + chapter_height / 2 - elseif options.timeline_chapters == 'lines' then - chapter_height = size - chapter_y = fay + (chapter_height / 2) - elseif options.timeline_chapters == 'lines-top' then - chapter_height = math.min(this.size_max / 3, size) - chapter_y = fay + (chapter_height / 2) - elseif options.timeline_chapters == 'lines-bottom' then - chapter_height = math.min(this.size_max / 3, size) - chapter_y = fay + size - (chapter_height / 2) - end - - if chapter_height ~= nil then - -- for 1px chapter size, use the whole size of the bar including padding - chapter_height = size <= 1 and foreground_size or chapter_height - local chapter_half_width = chapter_width / 2 - local chapter_half_height = chapter_height / 2 - local function draw_chapter(time) - local chapter_x = time_x + time_width * (time / state.duration) - local ax, bx = chapter_x - chapter_half_width, chapter_x + chapter_half_width - local cx, dx = math.max(ax, fax), math.min(bx, fbx) - local opts = { - color = options.color_foreground, - clip = dots and '\\iclip(' .. foreground_coordinates .. ')' or nil, - opacity = options.timeline_chapters_opacity, - } - - if dots then - -- 0.5 because clipping coordinates are rounded - if (ax - 0.5) < fax or (bx + 0.5) > fbx then - ass:circle(chapter_x, chapter_y, chapter_half_height, opts) - end - if (dx - cx) > 0 then -- intersection - opts.color = options.color_background - opts.clip = '\\clip(' .. foreground_coordinates .. ')' - ass:circle(chapter_x, chapter_y, chapter_half_height, opts) - end - else - ax, bx = round(ax), round(bx) - local ay, by = chapter_y - chapter_half_height, chapter_y + chapter_half_height - if ax < fax then --left of progress - ass:rect(ax, ay, math.min(bx, fax), by, opts) - end - if bx > fbx then --right of progress - ass:rect(math.max(ax, fbx), ay, bx, by, opts) - end - if (dx - cx) > 0 then --intersection - opts.color = options.color_background - ass:rect(cx, ay, dx, by, opts) - end - end - end - - if state.chapters ~= nil then - for i, chapter in ipairs(state.chapters) do - if not chapter._uosc_used_as_range_point then - draw_chapter(chapter.time) - end - end - end - - if state.ab_loop_a and state.ab_loop_a > 0 then - draw_chapter(state.ab_loop_a) - end - - if state.ab_loop_b and state.ab_loop_b > 0 then - draw_chapter(state.ab_loop_b) - end - end - end - - -- Seekable ranges - local function render_cache() - if options.timeline_cached_ranges and state.cached_ranges then - local range_height = math.max(math.floor(math.min(this.size_max / 8, foreground_size / 2)), 1) - local range_ay = fby - range_height - - for _, range in ipairs(state.cached_ranges) do - local range_start = math.max(type(range['start']) == 'number' and range['start'] or 0.000001, 0.000001) - local range_end = math.min(type(range['end']) and range['end'] or state.duration, state.duration) - ass:rect( - time_x + time_width * (range_start / state.duration), range_ay, - time_x + time_width * (range_end / state.duration), range_ay + range_height, - {color = options.timeline_cached_ranges.color, opacity = options.timeline_cached_ranges.opacity} - ) - end - - -- Visualize padded time area limits - if time_padding > 0 then - local notch_ay = math.max(range_ay - 2, fay) - local opts = {color = options.timeline_cached_ranges.color, opacity = options.timeline_opacity} - ass:rect(time_x, notch_ay, time_x + 1, bby, opts) - ass:rect(time_x + time_width - 1, notch_ay, time_x + time_width, bby, opts) - end - end - end - - -- Time values - local function render_time() - if text_opacity > 0 then - local opts = {size = this.font_size, opacity = math.min(options.timeline_opacity + 0.1, 1) * text_opacity} - - -- Elapsed time - if state.time_human then - local elapsed_x = bax + spacing - local elapsed_y = fay + (size / 2) - opts.color = options.color_foreground_text - opts.clip = '\\clip(' .. foreground_coordinates .. ')' - ass:txt(elapsed_x, elapsed_y, 4, state.time_human, opts) - opts.color = options.color_background_text - opts.clip = '\\iclip(' .. foreground_coordinates .. ')' - ass:txt(elapsed_x, elapsed_y, 4, state.time_human, opts) - end - - -- End time - if state.duration_or_remaining_time_human then - local end_x = bbx - spacing - local end_y = fay + (size / 2) - opts.color = options.color_foreground_text - opts.clip = '\\clip(' .. foreground_coordinates .. ')' - ass:txt(end_x, end_y, 6, state.duration_or_remaining_time_human, opts) - opts.color = options.color_background_text - opts.clip = '\\iclip(' .. foreground_coordinates .. ')' - ass:txt(end_x, end_y, 6, state.duration_or_remaining_time_human, opts) - end - end - end - - -- Render elements in the optimal order: - -- When line is minimized, it turns into a bar (timeline_line_width_minimized_scale), - -- so it should be below ranges and chapters. - -- But un-minimized it's a thin line that should be above everything. - if is_line and size > size_min then - render_ranges() - render_chapters() - render_progress() - render_cache() - render_time() - else - render_progress() - render_ranges() - render_chapters() - render_cache() - render_time() - end - - -- Hovered time and chapter - if (this.proximity_raw == 0 or this.pressed) and not (Elements.speed and Elements.speed.dragging) then - -- add 0.5 to be in the middle of the pixel - local hovered_seconds = this:get_time_at_x(cursor.x + 0.5) - local chapter_title, chapter_title_width = nil, nil - - if (options.timeline_chapters ~= 'never' and state.chapters) then - for i = #state.chapters, 1, -1 do - local chapter = state.chapters[i] - if hovered_seconds >= chapter.time then - if not chapter.is_end_only then - chapter_title = chapter.title_wrapped - chapter_title_width = chapter.title_wrapped_width - end - break - end - end - end - - -- 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.color_background or options.color_foreground - local line = {ax = cursor.x, ay = fay, bx = cursor.x + 1, by = fby} - ass:rect(line.ax, line.ay, line.bx, line.by, {color = color, opacity = 0.2}) - - -- Timestamp - ass:tooltip(line, format_time(hovered_seconds), {size = this.font_size, offset = 2}) - - -- Chapter title - if chapter_title then - ass:tooltip(line, chapter_title, { - offset = 2 + this.font_size * 1.4, size = this.font_size, bold = true, - text_length_override = chapter_title_width, - }) - end - end - - return ass -end - -function render_top_bar(this) - local visibility = this:get_visibility() - - if not this.enabled or visibility == 0 then return end - - local ass = assdraw.ass_new() - - if options.top_bar_controls then - -- Close button - local close = Elements.window_controls_close - if close.proximity_raw == 0 then - -- Background on hover - ass:rect(close.ax, close.ay, close.bx, close.by, {color = '2311e8', opacity = visibility}) - end - ass:icon( - close.ax + (this.button_width / 2), close.ay + (this.size / 2), this.icon_size, 'close', - {opacity = visibility, border = 1} - ) - - -- Maximize button - local maximize = Elements.window_controls_maximize - if maximize.proximity_raw == 0 then - -- Background on hover - ass:rect(maximize.ax, maximize.ay, maximize.bx, maximize.by, { - color = '222222', opacity = visibility, - }) - end - ass:icon( - maximize.ax + (this.button_width / 2), maximize.ay + (this.size / 2), this.icon_size, - 'crop_square', {opacity = visibility, border = 1} - ) - - -- Minimize button - local minimize = Elements.window_controls_minimize - if minimize.proximity_raw == 0 then - -- Background on hover - ass:rect(minimize.ax, minimize.ay, minimize.bx, minimize.by, { - color = '222222', opacity = visibility, - }) - end - ass:icon( - minimize.ax + (this.button_width / 2), minimize.ay + (this.size / 2), this.icon_size, 'minimize', - {opacity = visibility, border = 1} - ) - end - - -- Window title - if options.top_bar_title and (state.media_title or state.playlist_count > 1) then - local max_bx = this.title_bx - this.spacing - local text = state.media_title or 'n/a' - if state.playlist_count > 1 then - text = string.format('%d/%d - ', state.playlist_pos, state.playlist_count) .. text - end - - -- Background - local padding = this.font_size / 2 - local bg_margin = math.floor((this.size - this.font_size) / 4) - local bg_ax = this.ax + bg_margin - local bg_bx = math.min(max_bx, this.ax + text_width_estimate(text, this.font_size) + padding * 2) - ass:rect(bg_ax, this.ay + bg_margin, bg_bx, this.by - bg_margin, { - color = options.color_background, opacity = visibility * 0.8, radius = 2, - }) - - -- Text - ass:txt(bg_ax + padding, this.ay + (this.size / 2), 4, text, { - size = this.font_size, wrap = 2, color = 'FFFFFF', border = 1, border_color = '000000', opacity = visibility, - clip = string.format('\\clip(%d, %d, %d, %d)', this.ax, this.ay, max_bx, this.by), - }) - end - - return ass -end - -function render_volume(this) - local slider = Elements.volume_slider - local visibility = this:get_visibility() - - if this.width == 0 or visibility == 0 then return end - - local ass = assdraw.ass_new() - - if slider.height > 0 then - local nudge_y, nudge_size = slider.draw_nudge and slider.nudge_y or -infinity, slider.nudge_size - - -- Background bar coordinates - local bax, bay, bbx, bby = slider.ax, slider.ay, slider.bx, slider.by - - -- Foreground bar coordinates - local height_without_border = slider.height - (options.volume_border * 2) - local fax = slider.ax + options.volume_border - local fay = slider.ay + (height_without_border * (1 - math.min(state.volume / state.volume_max, 1))) + - options.volume_border - local fbx = slider.bx - options.volume_border - local fby = slider.by - options.volume_border - - -- Draws a rectangle with nudge at requested position - ---@param ax number - ---@param ay number - ---@param bx number - ---@param by number - function make_nudged_path(ax, ay, bx, by) - local fg_path = assdraw.ass_new() - fg_path:move_to(bx, by) - fg_path:line_to(ax, by) - local nudge_bottom_y = nudge_y + nudge_size - if ay <= nudge_bottom_y then - fg_path:line_to(ax, math.min(nudge_bottom_y)) - if ay <= nudge_y then - fg_path:line_to((ax + nudge_size), nudge_y) - local nudge_top_y = nudge_y - nudge_size - if ay <= nudge_top_y then - fg_path:line_to(ax, nudge_top_y) - fg_path:line_to(ax, ay) - fg_path:line_to(bx, ay) - fg_path:line_to(bx, nudge_top_y) - else - local triangle_side = ay - nudge_top_y - fg_path:line_to((ax + triangle_side), ay) - fg_path:line_to((bx - triangle_side), ay) - end - fg_path:line_to((bx - nudge_size), nudge_y) - else - local triangle_side = nudge_bottom_y - ay - fg_path:line_to((ax + triangle_side), ay) - fg_path:line_to((bx - triangle_side), ay) - end - fg_path:line_to(bx, nudge_bottom_y) - else - fg_path:line_to(ax, ay) - fg_path:line_to(bx, ay) - end - fg_path:line_to(bx, by) - return fg_path - end - - -- FG & BG paths - local fg_path = make_nudged_path(fax, fay, fbx, fby) - local bg_path = make_nudged_path(bax, bay, bbx, bby) - - -- Background - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_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.color_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(((this.width * 0.6) - (#volume_string * (this.width / 20))) * options.volume_font_scale) - local opacity = math.min(options.volume_opacity + 0.1, 1) * visibility - if fay < slider.by - slider.spacing then - ass:txt(slider.ax + (slider.width / 2), slider.by - slider.spacing, 2, volume_string, { - size = font_size, color = options.color_foreground_text, opacity = opacity, - clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', - }) - end - if fay > slider.by - slider.spacing - font_size then - ass:txt(slider.ax + (slider.width / 2), slider.by - slider.spacing, 2, volume_string, { - size = font_size, color = options.color_background_text, opacity = opacity, - clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', - }) - end - - -- Disabled stripes for no audio - if not state.has_audio then - -- Create 100 foreground clip path - local f100ax, f100ay = slider.ax + options.volume_border, slider.ay + options.volume_border - local f100bx, f100by = slider.bx - options.volume_border, slider.by - options.volume_border - local fg_100_path = make_nudged_path(f100ax, f100ay, f100bx, f100by) - - -- Render stripes - local stripe_height = 12 - local skew_height = stripe_height - local colors = {'000000', 'ffffff'} - - for c, color in ipairs(colors) do - local stripe_y = slider.ay + stripe_height * (c - 1) - - ass:new_event() - ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. color .. - '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')}') - ass:opacity(0.15 * opacity) - ass:pos(0, 0) - ass:draw_start() - - while stripe_y - skew_height < slider.by do - ass:move_to(slider.ax, stripe_y) - ass:line_to(slider.bx, stripe_y - skew_height) - ass:line_to(slider.bx, stripe_y - skew_height + stripe_height) - ass:line_to(slider.ax, stripe_y + stripe_height) - stripe_y = stripe_y + stripe_height * #colors - end - - ass:draw_stop() - end - end - end - - -- Mute button - local mute = Elements.volume_mute - local icon_name = state.mute and 'volume_off' or 'volume_up' - ass:icon( - mute.ax + (mute.width / 2), mute.by, mute.width * 0.7, icon_name, - {border = options.volume_border, opacity = options.volume_opacity * visibility, align = 2} - ) - return ass -end - -function render_speed(this) - if not this.dragging and (Elements.curtain.opacity > 0) then return end - - local visibility = this:get_visibility() - local opacity = this.dragging and 1 or visibility - - if opacity == 0 then return end - - local ass = assdraw.ass_new() - ass:rect(this.ax, this.ay, this.bx, this.by, { - color = options.color_background, radius = 2, opacity = opacity * 0.3, - }) - - -- Coordinates - local ax, ay = this.ax, this.ay - local bx, by = this.bx, ay + this.height - local half_width = (this.width / 2) - local half_x = ax + half_width - - -- Notches - local speed_at_center = state.speed - if this.dragging then - speed_at_center = this.dragging.start_speed + this.dragging.speed_distance - speed_at_center = math.min(math.max(speed_at_center, 0.01), 100) - end - local nearest_notch_speed = round(speed_at_center / this.notch_every) * this.notch_every - local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / this.notch_every) * this.notch_spacing) - local guide_size = math.floor(this.height / 7.5) - local notch_by = by - guide_size - local notch_ay_big = ay + round(this.font_size * 1.1) - local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2) - local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4) - local from_to_index = math.floor(this.notches / 2) - - for i = -from_to_index, from_to_index do - local notch_speed = nearest_notch_speed + (i * this.notch_every) - - if notch_speed >= 0 and notch_speed <= 100 then - local notch_x = nearest_notch_x + (i * this.notch_spacing) - local notch_thickness = 1 - local notch_ay = notch_ay_small - if (notch_speed % (this.notch_every * 10)) < 0.00000001 then - notch_ay = notch_ay_big - notch_thickness = 1.5 - elseif (notch_speed % (this.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.color_foreground, border = 1, border_color = options.color_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.color_foreground .. '\\3c&H' .. options.color_background .. '}') - ass:opacity(options.speed_opacity, opacity) - ass:pos(0, 0) - ass:draw_start() - ass:move_to(half_x, by - 2 - guide_size) - ass:line_to(half_x + guide_size, by - 2) - ass:line_to(half_x - guide_size, by - 2) - ass:draw_stop() - - -- Speed value - local speed_text = (round(state.speed * 100) / 100) .. 'x' - ass:txt(half_x, ay, 8, speed_text, { - size = this.font_size, color = options.color_background_text, - border = 1, border_color = options.color_background, opacity = opacity, - }) - - return ass -end - -function render_menu(this) - local ass = assdraw.ass_new() - - if this.parent_menu then - ass:merge(this.parent_menu:render()) - end - - local opacity = options.menu_opacity * this.opacity - local spacing = this.item_content_spacing - - -- Menu title - if this.title then - -- Background - ass:rect(this.ax, this.ay - this.item_height, this.bx, this.ay - 1, { - color = options.color_foreground, opacity = opacity, radius = 2, - }) - - -- Title - ass:txt(this.ax + this.width / 2, this.ay - (this.item_height * 0.5), 5, this.title, { - size = this.font_size, bold = true, color = options.color_foreground_text, - shadow = 1, shadow_color = options.color_foreground, wrap = 2, opacity = opacity, - clip = '\\clip(' .. this.ax .. ',' .. this.ay - this.item_height .. ',' .. this.bx .. ',' .. this.ay .. ')', - }) - end - - local scroll_area_clip = '\\clip(' .. this.ax .. ',' .. this.ay .. ',' .. this.bx .. ',' .. this.by .. ')' - - for index, item in ipairs(this.items) do - local item_ay = this.ay - this.scroll_y + (this.item_height * (index - 1) + this.item_spacing * (index - 1)) - local item_by = item_ay + this.item_height - local item_clip = nil - - if item_by >= this.ay and item_ay <= this.by then - -- Clip items overflowing scroll area - if item_ay <= this.ay or item_by >= this.by then - item_clip = scroll_area_clip - end - - local is_active = this.active_index == index - local font_color, background_color, shadow, shadow_color - local icon_size = this.font_size - local item_center_y = item_ay + (this.item_height / 2) - - if is_active then - font_color, background_color = options.color_foreground_text, options.color_foreground - shadow, shadow_color = 0, '' - else - font_color, background_color = options.color_background_text, options.color_background - shadow, shadow_color = 1, '\\4c&H' .. background_color - end - - local has_submenu = item.items ~= nil - -- controls title & hint clipping proportional to the ratio of their widths - local title_hint_ratio = 1 - if item.hint then - title_hint_ratio = item.title_width / (item.title_width + item.hint_width) - elseif has_submenu then - title_hint_ratio = item.title_width / (item.title_width + icon_size) - end - local title_hint_spacing = (title_hint_ratio == 1 or title_hint_ratio == 0) and 0 or spacing / 2 - - -- Background - ass:rect(this.ax, item_ay, this.bx, item_by, { - color = background_color, clip = item_clip, opacity = opacity, radius = 2, - }) - - -- Selected highlight - if this.selected_index == index then - ass:rect(this.ax + 1, item_ay + 1, this.bx - 1, item_by - 1, { - color = options.color_foreground, clip = item_clip, opacity = 0.1 * this.opacity, radius = 1, - }) - end - - -- Title - local title_x = this.ax + spacing - local title_hint_cut_x = title_x + (this.width - spacing * 2) * title_hint_ratio - if item.title then - item.ass_save_title = item.ass_save_title or item.title:gsub('([{}])', '\\%1') - local clip = '\\clip(' .. this.ax .. ',' .. math.max(item_ay, this.ay) .. ',' - .. round(title_hint_cut_x - title_hint_spacing / 2) .. ',' .. math.min(item_by, this.by) .. ')' - ass:txt(title_x, item_center_y, 4, item.ass_save_title, { - size = this.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2, - shadow = 1, shadow_color = background_color, opacity = this.opacity * (item.muted and 0.5 or 1), - clip = clip, - }) - end - - -- Hint - local hint_x = this.bx - spacing - if item.hint then - item.ass_save_hint = item.ass_save_hint or item.hint:gsub('([{}])', '\\%1') - local clip = '\\clip(' .. round(title_hint_cut_x + title_hint_spacing / 2) .. ',' .. - math.max(item_ay, this.ay) .. ',' .. this.bx .. ',' .. math.min(item_by, this.by) .. ')' - ass:txt(hint_x, item_center_y, 6, item.ass_save_hint, { - size = this.font_size_hint, color = font_color, shadow = shadow, shadow_color = shadow_color, - wrap = 2, opacity = (has_submenu and 1 or 0.5) * this.opacity, clip = clip, - }) - elseif has_submenu then - ass:icon(hint_x - (icon_size / 2), item_center_y, icon_size * 1.5, 'chevron_right', { - color = is_active and options.color_background or options.color_foreground, - border = 0, opacity = this.opacity, clip = item_clip, - }) - end - end - end - - -- Scrollbar - if this.scroll_height > 0 then - local groove_height = this.height - 2 - local thumb_height = math.max((this.height / (this.scroll_height + this.height)) * groove_height, 40) - local thumb_y = this.ay + 1 + ((this.scroll_y / this.scroll_height) * (groove_height - thumb_height)) - ass:rect(this.bx - 3, thumb_y, this.bx - 1, thumb_y + thumb_height, { - color = options.color_foreground, opacity = options.menu_opacity * this.opacity * 0.8, - }) - end - - return ass -end - --- MAIN RENDERING +--[[ RENDERING ]] -- Request that render() is called. -- The render is then either executed immediately, or rate-limited if it was @@ -2340,1026 +1316,2251 @@ function render() update_margins() end --- Element creators +--[[ ELEMENT ]] --- Speed ----@param props {anchor_id: string} -function create_speed_slider(props) - return Element.new('speed', { - enabled = true, - anchor_id = props.anchor_id, - dragging = nil, - width = 0, - height = 0, - notches = 10, - notch_every = 0.1, - font_size = nil, - get_visibility = function(this) - -- We force inherit, because I want to see speed value when peeking timeline - local this_visibility = Element.get_visibility(this) - return Elements.timeline.proximity_raw ~= 0 - and math.max(Elements.timeline.proximity, this_visibility) or this_visibility - end, - set_coordinates = function(this, ax, ay, bx, by) - this.ax, this.ay, this.bx, this.by = ax, ay, bx, by - this.height, this.width = this.by - this.ay, this.bx - this.ax - this.notch_spacing = this.width / (this.notches + 1) - this.font_size = round(this.height * 0.48 * options.speed_font_scale) - end, - speed_step = function(this, 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, - on_mbtn_left_down = function(this) - this:tween_stop() -- Stop and cleanup possible ongoing animations - this.dragging = { - start_time = mp.get_time(), - start_x = cursor.x, - distance = 0, - speed_distance = 0, - start_speed = state.speed, - } - end, - on_global_mouse_move = function(this) - if not this.dragging then return end +---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;} - this.dragging.distance = cursor.x - this.dragging.start_x - this.dragging.speed_distance = (-this.dragging.distance / this.notch_spacing * this.notch_every) +-- Base class all elements inherit from. +---@class Element : Class +local Element = class() - local speed_current = state.speed - local speed_drag_current = this.dragging.start_speed + this.dragging.speed_distance - speed_drag_current = math.min(math.max(speed_drag_current, 0.01), 100) - local drag_dir_up = speed_drag_current > speed_current - - local speed_step_next = speed_current - local speed_drag_diff = math.abs(speed_drag_current - speed_current) - while math.abs(speed_step_next - speed_current) < speed_drag_diff do - speed_step_next = this:speed_step(speed_step_next, drag_dir_up) - end - local speed_step_prev = this:speed_step(speed_step_next, not drag_dir_up) - - local speed_new = speed_step_prev - local speed_next_diff = math.abs(speed_drag_current - speed_step_next) - local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev) - if speed_next_diff < speed_prev_diff then - speed_new = speed_step_next - end - - if speed_new ~= speed_current then - mp.set_property_native('speed', speed_new) - end - end, - on_mbtn_left_up = function(this) - -- Reset speed on short clicks - if this.dragging and math.abs(this.dragging.distance) < 6 and mp.get_time() - this.dragging.start_time < 0.15 then - mp.set_property_native('speed', 1) - end - end, - on_global_mbtn_left_up = function(this) - this.dragging = nil - request_render() - end, - on_global_mouse_leave = function(this) - this.dragging = nil - request_render() - end, - on_wheel_up = function(this) - mp.set_property_native('speed', this:speed_step(state.speed, true)) - end, - on_wheel_down = function(this) - mp.set_property_native('speed', this:speed_step(state.speed, false)) - end, - render = render_speed, - }) -end - --- Button ---@param id string ----@param props {icon: string; on_click: function; anchor_id?: string; active?: boolean; foreground?: string; background?: string; tooltip?: string} -function create_button(id, props) - return Element.new(id, { - enabled = true, - anchor_id = props.anchor_id, - icon = props.icon, - active = props.active, - tooltip = props.tooltip, - foreground = props.foreground or options.color_foreground, - background = props.background or options.color_background, - set_coordinates = function(this, ax, ay, bx, by) - this.ax, this.ay, this.bx, this.by = ax, ay, bx, by - this.font_size = round((this.by - this.ay) * 0.7) - end, - on_mbtn_left_down = function(this) - -- 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, props.on_click) - end, - render = function(this) - local visibility = this:get_visibility() - if visibility == 0 then return end +---@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 elements visibility. + 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 - local ass = assdraw.ass_new() - local is_hover = this.proximity_raw == 0 - local is_hover_or_active = is_hover or this.active - local foreground = this.active and this.background or this.foreground - local background = this.active and this.foreground or this.background + if props then table_assign(self, props) end - -- Background - if is_hover_or_active then - ass:rect(this.ax, this.ay, this.bx, this.by, { - color = this.active and background or foreground, radius = 2, - opacity = visibility * (this.active and 0.8 or 0.2), - }) - end - - -- Tooltip on hover - if is_hover and this.tooltip then ass:tooltip(this, this.tooltip) end - - -- Icon - local x, y = round(this.ax + (this.bx - this.ax) / 2), round(this.ay + (this.by - this.ay) / 2) - ass:icon(x, y, this.font_size, this.icon, { - color = foreground, border = this.active and 0 or 1, border_color = background, opacity = visibility, - }) - - return ass - end, - }) -end - --- Cycle prop button ----@alias CycleState {value: any; icon: string; active?: boolean} ----@param id string ----@param props {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string} -function create_cycle_button(id, props) - local prop = props.prop - local states = props.states - local current_state_index = 1 - local button = create_button(id, { - anchor_id = props.anchor_id, icon = states[1].icon, active = states[1].active, tooltip = props.tooltip, - on_click = function() - local new_state = states[current_state_index + 1] or states[1] - mp.set_property(prop, new_state.value) - end, - }) - - local function handle_change(name, value) - local index = itable_find(states, function(state) return state.value == value end) - current_state_index = index or 1 - button.icon = states[current_state_index].icon - button.active = states[current_state_index].active - request_render() - end - - mp.observe_property(prop, 'string', handle_change) - function button:destroy() mp.unobserve_property(handle_change) end - - return button -end - --- STATIC ELEMENTS - -Elements:add(Element.new('window_border', { - enabled = false, - size = 0, -- set in decide_enabled - decide_enabled = function(this) - this.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border - this.size = this.enabled and options.window_border_size or 0 - end, - on_prop_border = function(this) this:decide_enabled() end, - on_prop_fullormaxed = function(this) this:decide_enabled() end, - render = function(this) - if this.size > 0 then - local ass = assdraw.ass_new() - local clip = '\\iclip(' .. this.size .. ',' .. this.size .. ',' .. - (display.width - this.size) .. ',' .. (display.height - this.size) .. ')' - ass:rect(0, 0, display.width, display.height, { - color = options.color_background, clip = clip, opacity = options.window_border_opacity, - }) - return ass - end - end, -})) -Elements:add(Element.new('pause_indicator', { - enabled = true, - ignores_menu = true, - base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8, - paused = state.pause, - type = options.pause_indicator, - is_manual = options.pause_indicator == 'manual', - fadeout_requested = false, - opacity = 0, - init = function(this) - mp.observe_property('pause', 'bool', function(_, paused) - if options.pause_indicator == 'flash' then - if this.paused == paused then return end - this:flash() - elseif options.pause_indicator == 'static' then - this:decide() - end + -- 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, - flash = function(this) - if not this.is_manual and this.type ~= 'flash' then return end - -- can't wait for pause property event listener to set this, because when this is used inside a binding like: - -- cycle pause; script-binding uosc/flash-pause-indicator - -- the pause event is not fired fast enough, and indicator starts rendering with old icon - this.paused = mp.get_property_native('pause') - if this.is_manual then this.type = 'flash' end - this.opacity = 1 - this:tween_property('opacity', 1, 0, 0.15) - end, - -- decides whether static indicator should be visible or not - decide = function(this) - if not this.is_manual and this.type ~= 'static' then return end - this.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary - if this.is_manual then this.type = 'static' end - this.opacity = this.paused and 1 or 0 - request_render() + end) + self._flash_out_timer:kill() - -- works around an mpv race condition bug during pause on windows builds, which cause osd updates to be ignored - -- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more - mp.add_timeout(.05, function() osd:update() end) - end, - render = function(this) - if this.opacity == 0 then return end - - local ass = assdraw.ass_new() - local is_static = this.type == 'static' - - -- Background fadeout - if is_static then - ass:rect(0, 0, display.width, display.height, { - color = options.color_background, opacity = this.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 - this.opacity) - - if this.paused then - ass:icon( - display.width / 2, display.height / 2, size, 'pause', - {border = 1, opacity = this.base_icon_opacity * this.opacity} - ) - else - ass:icon( - display.width / 2, display.height / 2, size * 1.2, 'play_arrow', - {border = 1, opacity = this.base_icon_opacity * this.opacity} - ) - end - - return ass - end, -})) -Elements:add(Element.new('timeline', { - enabled = true, - pressed = false, - size_max = 0, size_min = 0, -- set in `on_display_change` handler based on `state.fullormaxed` - size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command - font_size = 0, -- calculated in on_display_change - top_border = options.timeline_border, - get_visibility = function(this) - return Elements.controls - and math.max(Elements.controls.proximity, Element.get_visibility(this)) or Element.get_visibility(this) - end, - decide_enabled = function(this) - this.enabled = state.duration and state.duration > 0 and state.time - end, - get_effective_size_min = function(this) - return this.size_min_override or this.size_min - end, - get_effective_size = function(this) - if Elements.speed and Elements.speed.dragging then return this.size_max end - local size_min = this:get_effective_size_min() - return size_min + math.ceil((this.size_max - size_min) * this:get_visibility()) - end, - get_effective_line_width = function(this) - return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width - end, - update_dimensions = function(this) - if state.fullormaxed then - this.size_min = options.timeline_size_min_fullscreen - this.size_max = options.timeline_size_max_fullscreen - else - this.size_min = options.timeline_size_min - this.size_max = options.timeline_size_max - end - this.font_size = math.floor(math.min((this.size_max + 60) * 0.2, this.size_max * 0.96) * options.timeline_font_scale) - this.ax = Elements.window_border.size - this.ay = display.height - Elements.window_border.size - this.size_max - this.top_border - this.bx = display.width - Elements.window_border.size - this.by = display.height - Elements.window_border.size - this.width = this.bx - this.ax - end, - on_prop_duration = function(this) this:decide_enabled() end, - on_prop_time = function(this) this:decide_enabled() end, - on_prop_border = function(this) this:update_dimensions() end, - on_prop_fullormaxed = function(this) this:update_dimensions() end, - on_display_change = function(this) this:update_dimensions() end, - get_time_at_x = function(this, x) - -- padding serves the purpose of matching cursor to timeline_style=line exactly - local padding = (options.timeline_style == 'line' and this:get_effective_line_width() or 0) / 2 - local progress = math.max(0, math.min((x - this.ax - padding) / (this.width - padding * 2), 1)) - return state.duration * progress - end, - set_from_cursor = function(this) - -- add 0.5 to be in the middle of the pixel - mp.commandv('seek', this:get_time_at_x(cursor.x + 0.5), 'absolute+exact') - end, - on_mbtn_left_down = function(this) - this.pressed = true - this:set_from_cursor() - end, - on_global_mbtn_left_up = function(this) this.pressed = false end, - on_global_mouse_leave = function(this) this.pressed = false end, - on_global_mouse_move = function(this) - if this.pressed then this:set_from_cursor() end - end, - on_wheel_up = function(this) - mp.commandv('seek', options.timeline_step) - end, - on_wheel_down = function(this) - mp.commandv('seek', -options.timeline_step) - end, - render = render_timeline, -})) -Elements:add(Element.new('top_bar', { - enabled = false, - decide_enabled = function(this) - if options.top_bar == 'no-border' then - this.enabled = not state.border or state.fullscreen - elseif options.top_bar == 'always' then - this.enabled = true - else - this.enabled = false - end - this.enabled = this.enabled and (options.top_bar_controls or options.top_bar_title) - - -- Propagate enabled flag to child elements - local children = { - Elements.window_controls_minimize, - Elements.window_controls_maximize, - Elements.window_controls_close, - } - for _, element in ipairs(children) do - element.enabled = this.enabled - end - end, - update_dimensions = function(this) - this.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size - this.icon_size = round(this.size * 0.5) - this.spacing = math.ceil(this.size * 0.25) - this.font_size = math.floor(this.size - (this.spacing * 2)) - this.button_width = round(this.size * 1.15) - this.ay = Elements.window_border.size - this.bx = display.width - Elements.window_border.size - this.by = this.size + Elements.window_border.size - this.title_bx = this.bx - (options.top_bar_controls and (this.button_width * 3) or 0) - this.ax = options.top_bar_title and Elements.window_border.size or this.title_bx - end, - on_prop_border = function(this) - this:decide_enabled() - this:update_dimensions() - end, - on_prop_fullscreen = function(this) - this:decide_enabled() - this:update_dimensions() - end, - on_prop_maximized = function(this) - this:decide_enabled() - this:update_dimensions() - end, - on_display_change = function(this) this:update_dimensions() end, - render = render_top_bar, -})) -if options.top_bar_controls then - Elements:add(Element.new('window_controls_minimize', { - update_dimensions = function(this) - this.ax = Elements.top_bar.bx - (Elements.top_bar.button_width * 3) - this.ay = Elements.top_bar.ay - this.bx = this.ax + Elements.top_bar.button_width - this.by = this.ay + Elements.top_bar.size - end, - on_prop_border = function(this) this:update_dimensions() end, - on_display_change = function(this) this:update_dimensions() end, - on_mbtn_left_down = function(this) - if this.enabled then mp.commandv('cycle', 'window-minimized') end - end, - })) - Elements:add(Element.new('window_controls_maximize', { - update_dimensions = function(this) - this.ax = Elements.top_bar.bx - (Elements.top_bar.button_width * 2) - this.ay = Elements.top_bar.ay - this.bx = this.ax + Elements.top_bar.button_width - this.by = this.ay + Elements.top_bar.size - end, - on_prop_border = function(this) this:update_dimensions() end, - on_display_change = function(this) this:update_dimensions() end, - on_mbtn_left_down = function(this) - if this.enabled then mp.commandv('cycle', 'window-maximized') end - end, - })) - Elements:add(Element.new('window_controls_close', { - update_dimensions = function(this) - this.ax = Elements.top_bar.bx - Elements.top_bar.button_width - this.ay = Elements.top_bar.ay - this.bx = this.ax + Elements.top_bar.button_width - this.by = this.ay + Elements.top_bar.size - end, - on_prop_border = function(this) this:update_dimensions() end, - on_display_change = function(this) this:update_dimensions() end, - on_mbtn_left_down = function(this) - if this.enabled then mp.commandv('quit') end - end, - })) + Elements:add(self) end -if options.controls and options.controls ~= 'never' then - Elements:add(Element.new('controls', { - enabled = true, - -- Table of controls, each with these props: - -- element?: Element; - -- sizing: 'space' | 'static' | 'dynamic'; - -- scale: number; - options.controls_size scale factor - -- ratio?: number; - width/height ratio of a static or dynamic element - -- ratio_min?: number; min ratio for 'dynamic' sized element - -- skip: boolean; - whether it should be skipped, determined during layout phase - controls = {}, - init = function(this) this:serialize() end, - serialize = function(this) - local shorthands = { - menu = 'command:menu:script-binding uosc/menu?Menu', - subtitles = 'command:subtitles:script-binding uosc/subtitles?Subtitles', - audio = 'command:audiotrack:script-binding uosc/audio?Audio', - ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device', - video = 'command:theaters:script-binding uosc/video?Video', - playlist = 'command:list_alt:script-binding uosc/playlist?Playlist', - chapters = 'command:bookmarks:script-binding uosc/chapters?Chapters', - ['stream-quality'] = 'command:deblur: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 configs - 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 == '<' then in_disposition = true - elseif c == '>' 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 - - -- Filter out based on disposition - items = itable_filter(items, function(item) - if item.disposition == '' then return true end - local dispositions = split(item.disposition, ' *, *') - for _, disposition in ipairs(dispositions) do - local value = disposition:sub(1, 1) ~= '!' - local name = not value and disposition:sub(2) or disposition - local prop = name == 'has_audio' and name or 'is_' .. name - if state[prop] ~= value then return false end - end - return true - end) - - -- Create controls - this.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, ' *%? *') - config = config_tooltip[1] - local tooltip = config_tooltip[2] - local parts = split(config, ' *: *') - local kind, params = parts[1], itable_slice(parts, 2) - - -- Convert toggles into cycles - if kind == 'toggle' then - kind = 'cycle' - params[#params + 1] = 'no/yes!' - end - - if kind == 'space' then - this.controls[#this.controls + 1] = {kind = kind, sizing = 'space'} - elseif kind == 'gap' then - this.controls[#this.controls + 1] = { - kind = kind, 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 = create_button('control_' .. i, { - icon = params[1], - anchor_id = 'controls', - on_click = function() mp.command(params[2]) end, - tooltip = tooltip, - }) - this.controls[#this.controls + 1] = { - kind = kind, element = element, sizing = 'static', scale = 1, ratio = 1, - } - Elements:add(element) - 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 = create_cycle_button('control_' .. i, { - prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip, - }) - this.controls[#this.controls + 1] = { - kind = kind, element = element, sizing = 'static', scale = 1, ratio = 1, - } - Elements:add(element) - end - elseif kind == 'speed' then - if not Elements.speed then - local element = create_speed_slider({anchor_id = 'controls'}) - this.controls[#this.controls + 1] = { - kind = kind, element = element, - sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2, - } - Elements:add(element) - else - msg.error('there can only be 1 speed slider') - end - end - end - - this:update_dimensions() - end, - clean_controls = function(this) - for _, control in ipairs(this.controls) do - if control.element then Elements:remove(control.element) end - end - this.controls = {} - request_render() - end, - get_visibility = function(this) - return (Elements.speed and Elements.speed.dragging) and 1 or - Elements.timeline.proximity_raw == 0 and -1 or Element.get_visibility(this) - end, - update_dimensions = function(this) - 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 - this.bx = display.width - window_border - margin - this.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin - this.ax, this.ay = window_border + margin, this.by - size - - -- Re-enable all elements - for c, control in ipairs(this.controls) do - control.hide = false - if control.element then control.element.enabled = true end - end - - -- Controls - local available_width = this.bx - this.ax - local statics_width = (#this.controls - 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(this.controls) 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(#this.controls / 2 + 0.1) - for a = 0, #this.controls - 1, 1 do - i = i + (a * (a % 2 == 0 and 1 or -1)) - local control = this.controls[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 = this.ax - local width_for_dynamics = available_width - statics_width - local space_width = (width_for_dynamics - max_dynamics_width) / spaces - - for c, control in ipairs(this.controls) 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(this.by - height), bx, this.by) end - current_x = bx + spacing - end - end - - request_render() - end, - on_dispositions = function(this) - this:clean_controls() - this:serialize() - end, - on_display_change = function(this) this:update_dimensions() end, - on_prop_border = function(this) this:update_dimensions() end, - on_prop_fullormaxed = function(this) this:update_dimensions() end, - })) +function Element:destroy() + self.destroyed = true + Elements:remove(self) end -if itable_index_of({'left', 'right'}, options.volume) then - Elements:add(Element.new('volume', { - enabled = true, - width = nil, -- set in `update_dimensions` - height = nil, -- set in `update_dimensions` - margin = nil, -- set in `update_dimensions` - update_dimensions = function(this) - this.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 padding_top = top_bar.enabled and top_bar.size or 0 - local padding_bottom = (timeline.enabled and timeline.size_max or 0) + - (controls and controls.enabled and controls.by - controls.ay or 0) - local available_height = display.height - padding_top - padding_bottom - local max_height = available_height * 0.8 - this.height = round(math.min(this.width * 8, max_height)) - this.enabled = this.height > this.width * 2 -- don't render if too small - this.margin = (this.width / 2) + Elements.window_border.size - this.ax = round(options.volume == 'left' and this.margin or display.width - this.margin - this.width) - this.ay = padding_top + round((available_height - this.height) / 2) - this.bx = round(this.ax + this.width) - this.by = round(this.ay + this.height) - end, - on_display_change = function(this) this:update_dimensions() end, - on_prop_border = function(this) this:update_dimensions() end, - render = render_volume, - })) - Elements:add(Element.new('volume_mute', { - enabled = true, - width = 0, - height = 0, - on_display_change = function(this) - this.width = Elements.volume.width - this.height = round(this.width * 0.8) - this.ax, this.ay = Elements.volume.ax, Elements.volume.by - this.height - this.bx, this.by = Elements.volume.bx, Elements.volume.by - end, - on_mbtn_left_down = function(this) mp.commandv('cycle', 'mute') end, - })) - Elements:add(Element.new('volume_slider', { - enabled = true, - pressed = false, - width = 0, - height = 0, - nudge_y = 0, -- vertical position where volume overflows 100 - nudge_size = nil, -- set on resize - font_size = nil, - spacing = nil, - on_display_change = function(this) - if state.volume_max == nil or state.volume_max == 0 then return end - this.ax, this.ay = Elements.volume.ax, Elements.volume.ay - this.bx, this.by = Elements.volume.bx, Elements.volume_mute.ay - this.width, this.height = this.bx - this.ax, this.by - this.ay - this.nudge_y = this.by - round(this.height * (100 / state.volume_max)) - this.nudge_size = round(Elements.volume.width * 0.18) - this.draw_nudge = this.ay < this.nudge_y - this.spacing = round(this.width * 0.2) - end, - set_volume = function(this, volume) - volume = round(volume / options.volume_step) * options.volume_step - if state.volume == volume then return end - mp.commandv('set', 'volume', math.max(math.min(volume, state.volume_max), 0)) - end, - set_from_cursor = function(this) - local volume_fraction = (this.by - cursor.y - options.volume_border) / (this.height - options.volume_border) - this:set_volume(volume_fraction * state.volume_max) - end, - on_mbtn_left_down = function(this) - this.pressed = true - this:set_from_cursor() - end, - on_global_mbtn_left_up = function(this) this.pressed = false end, - on_global_mouse_leave = function(this) this.pressed = false end, - on_global_mouse_move = function(this) - if this.pressed then this:set_from_cursor() end - end, - on_wheel_up = function(this) this:set_volume(state.volume + options.volume_step) end, - on_wheel_down = function(this) this:set_volume(state.volume - options.volume_step) 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 -Elements:add(Element.new('curtain', { - enabled = true, - ignores_menu = true, - opacity = 0, - fadeout = function(this) - this:tween_property('opacity', this.opacity, 0); - end, - fadein = function(this) - this:tween_property('opacity', this.opacity, 1); - end, - render = function(this) - if this.opacity > 0 and options.curtain_opacity > 0 then - local ass = assdraw.ass_new() - ass:rect(0, 0, display.width, display.height, { - color = options.color_background, opacity = options.curtain_opacity * this.opacity, - }) - return ass + +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 - (math.min(math.max(self.proximity_raw - options.proximity_in, 0), 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 proximity + if self.forced_visibility then return self.forced_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 self.forced_visibility or (anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_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 + call_me_maybe(callback) end - end, -})) - --- CHAPTERS SERIALIZATION - --- Parse `chapter_ranges` option into workable data structure -for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do - local start_patterns, color, opacity, end_patterns = string.match( - definition, - '([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)' ) +end - -- Valid definition - if start_patterns then - start_patterns = start_patterns:lower() - end_patterns = end_patterns:lower() - local uses_bof = start_patterns:find('{bof}') ~= nil - local uses_eof = end_patterns:find('{eof}') ~= nil - local chapter_range = { - start_patterns = split(start_patterns, '|'), - end_patterns = split(end_patterns, '|'), - color = color, - opacity = tonumber(opacity), - ranges = {}, - } +function Element:is_tweening() return self and self._kill_tween end +function Element:tween_stop() self:maybe('_kill_tween') end - -- Filter out special keywords so we don't use them when matching titles - if uses_bof then - chapter_range.start_patterns = itable_remove(chapter_range.start_patterns, '{bof}') - end - if uses_eof and chapter_range.end_patterns then - chapter_range.end_patterns = itable_remove(chapter_range.end_patterns, '{eof}') - end +-- 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 - chapter_range['serialize'] = function(chapters) - chapter_range.ranges = {} - local current_range = nil - -- bof and eof should be used only once per timeline - -- eof is only used when last range is missing end - local bof_used = false +---@param name string +function Element:trigger(name, ...) + self:maybe('on_' .. name, ...) + request_render() +end - local function start_range(chapter) - -- If there is already a range started, should we append or overwrite? - -- I chose overwrite here. - current_range = {['start'] = chapter} - end - - local function end_range(chapter) - current_range['end'] = chapter - chapter_range.ranges[#chapter_range.ranges + 1] = current_range - -- Mark both chapter objects - current_range['start']._uosc_used_as_range_point = true - current_range['end']._uosc_used_as_range_point = true - -- Clear for next range - current_range = nil - end - - for _, chapter in ipairs(chapters) do - if type(chapter.title) == 'string' then - local lowercase_title = chapter.title:lower() - - -- Is ending check and handling - if chapter_range.end_patterns then - chapter.is_end_only = false - for _, end_pattern in ipairs(chapter_range.end_patterns) do - if lowercase_title:find(end_pattern) then - if current_range == nil and uses_bof and not bof_used then - bof_used = true - start_range({time = 0}) - end - if current_range ~= nil then - end_range(chapter) - end - chapter.is_end_only = end_pattern ~= '.*' - break - end - end - end - - -- Is start check and handling - for _, start_pattern in ipairs(chapter_range.start_patterns) do - if lowercase_title:find(start_pattern) then - start_range(chapter) - chapter.is_end_only = false - break - end - end - end - end - - -- If there is an unfinished range and range type accepts eof, use it - if current_range ~= nil and uses_eof then - end_range({time = state.duration or infinity}) - end - end - - state.chapter_ranges = state.chapter_ranges or {} - state.chapter_ranges[#state.chapter_ranges + 1] = chapter_range +-- 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 -function parse_chapters() - -- Sometimes state.duration is not initialized yet for some reason - state.duration = mp.get_property_native('duration') +--[[ MENU ]] +--[[ +Usage: +``` +local data = { + type = 'foo', + title = 'Foo', + items = { + {title = 'Foo title', hint = 'Ctrl+F', value = 'foo'}, + {title = 'Submenu', items = {...}} + } +} - local chapters = get_normalized_chapters() +function open_item(value) + -- do something with value +end - if not chapters or not state.duration then return end +local menu = Menu:open(items, open_item) +menu.update(new_data) +menu.update_items(new_items) +menu.close() +``` +]] - -- Reset custom ranges - for _, chapter_range in ipairs(state.chapter_ranges or {}) do - chapter_range.serialize(chapters) +-- Menu data structure accepted by `Menu:open(menu)`. +---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected?: boolean;} +---@alias MenuDataItem MenuDataValue|MenuData +---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; selected?: boolean; keep_open?: boolean; separator?: boolean;} +---@alias MenuOptions {on_open?: fun(), on_close?: fun()} + +-- Internal data structure created from `Menu`. +---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; active_index?: number; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; width: number; height: number; top: number; scroll_y: number; scroll_height: number; selected?: boolean; 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; 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) + if self:is_open() then self: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() + call_me_maybe(callback) + 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 +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 Map of submenus by their ids, such as `'Tools > Aspect ratio'`. + self.by_id = {} + self.key_bindings = {} + self.is_closing = false + + self:update(data) + + for _, menu in ipairs(self.all) do + self:scroll_to_index(menu.selected_index or menu.active_index, menu) end - for _, chapter in ipairs(chapters) do - chapter.title_wrapped, chapter.title_wrapped_width = wrap_text(chapter.title, 25) - chapter.title_wrapped = ass_escape(chapter.title_wrapped) + self:tween_property('opacity', 0, 1) + self:enable_key_bindings() + Elements.curtain:fadein() + call_me_maybe(self.opts.on_open) +end + +---@param data MenuData +function Menu:update(data) + self.type = data.type + + local new_root = {is_root = true} + 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' + menu.title_length = text_length(menu.title) + menu.hint_length = text_length(menu.title) + + -- Update items + local selected_index = nil + local active_index = nil + menu.items = {} + + for i, item_data in ipairs(menu_data.items or {}) do + if item_data.selected then selected_index = i end + if item_data.active then active_index = i end + + local item = {} + table_assign(item, item_data, { + 'title', 'icon', 'hint', '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 = selected_index or active_index or (#menu.items > 0 and 1 or nil) + end + menu.active_index = active_index + + -- 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 - state.chapters = chapters + 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.menu_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(math.min(math.max(menu.max_width, min_width), display.width * 0.9)) + local title_height = (menu.is_root and menu.title) and self.scroll_step or 0 + local title_top_adjustment = title_height > 0 and self.scroll_step / 2 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((display.height - menu.height) / 2 + title_top_adjustment) + 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.active_index or (menu.items and #menu.items > 0 and 1 or nil)) + self:activate_index(menu.active_index) + 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 --- CONTEXT MENU SERIALIZATION +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 -state.context_menu_items = (function() - local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'}) - local input_conf_meta, meta_error = utils.file_info(input_conf_path) +function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end - -- File doesn't exist - if not input_conf_meta or not input_conf_meta.is_file then return 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 - local main_menu = {items = {}, items_by_command = {}} - local submenus_by_id = {} +---@param pos? number +---@param menu? MenuStack +function Menu:scroll_to(pos, menu) + menu = menu or self.current + menu.scroll_y = math.max(math.min(pos or 0, menu.scroll_height), 0) + request_render() +end - 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*$') +---@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 index? integer +---@param menu? MenuStack +function Menu:activate_index(index, menu) + menu = menu or self.current + menu.active_index = (index and index >= 1 and index <= #menu.items) and index or nil + request_render() +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 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 + local previous_active_value = menu.active_index and menu.items[menu.active_index].value or nil + table.remove(menu.items, index) + self:update_content_dimensions() + if previous_active_value then self:activate_value(previous_active_value, menu) end + 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 - if key then - local is_dummy = key:sub(1, 1) == '#' - local submenu_id = '' - local target_menu = main_menu - local title_parts = split(title or '', ' *> *') + end +end - 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 +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 - if not submenus_by_id[submenu_id] then - local items = {} - submenus_by_id[submenu_id] = {items = items, items_by_command = {}} - target_menu.items[#target_menu.items + 1] = {title = title_part, items = items} - end +function Menu:on_display() self:update_dimensions() end +function Menu:on_prop_fullormaxed() self:update_content_dimensions() end - target_menu = submenus_by_id[submenu_id] - else - if command == 'ignore' then break end - -- If command is already in menu, just append the key to it - if target_menu.items_by_command[command] then - local hint = target_menu.items_by_command[command].hint - target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key - else - local item = { - title = title_part, - hint = not is_dummy and key or nil, - value = command, - } - target_menu.items_by_command[command] = item - target_menu.items[#target_menu.items + 1] = item - end - end +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) + 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) + 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 = math.min(math.max(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 = math.min(math.max(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:destroy() + Element.destroy(self) + self:disable_key_bindings() + Elements.curtain:fadeout() + call_me_maybe(self.opts.on_close) +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')) + + if options.menu_wasd_navigation then + self:add_key_binding('w', 'menu-prev2', self:create_action('prev'), 'repeatable') + self:add_key_binding('a', 'menu-back2', self:create_action('back')) + self:add_key_binding('s', 'menu-next2', self:create_action('next'), 'repeatable') + self:add_key_binding('d', 'menu-select2', self:create_action('open_selected_item_preselect')) + self:add_key_binding('shift+d', 'menu-select-soft2', self:create_action('open_selected_item_soft')) + end + + if options.menu_hjkl_navigation then + self:add_key_binding('h', 'menu-back3', self:create_action('back')) + self:add_key_binding('j', 'menu-next3', self:create_action('next'), 'repeatable') + self:add_key_binding('k', 'menu-prev3', self:create_action('prev'), 'repeatable') + self:add_key_binding('l', 'menu-select3', self:create_action('open_selected_item_preselect')) + self:add_key_binding('shift+l', 'menu-select-soft3', self:create_action('open_selected_item_soft')) + end + + 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('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, active_index = menu.selected_index or -1, menu.active_index or -1 + + -- Background + ass:rect(ax, ay - (draw_title and self.item_height or 0) - 3, bx, by + 3, { + color = options.color_background, opacity = opacity, radius = 4, + }) + + for index = start_index, end_index, 1 do + local item = menu.items[index] + + 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 = active_index == index and options.color_foreground_text or options.color_background_text + local shadow_color = active_index == index and options.color_foreground or options.color_background + + -- Separator + local separator_size = (item.separator and 3 or 1) + local hide_separator = separator_size == 1 and ( + active_index == index or selected_index == index or + active_index - 1 == index or selected_index - 1 == index + ) + if not hide_separator and item_by < by then + local sep_ay = item_by - math.floor(separator_size / 2) + ass:rect(ax + 3, sep_ay, bx - 3, sep_ay + separator_size, { + color = options.color_foreground, opacity = opacity * 0.13, + }) + end + + -- Highlight + local highlight_opacity = 0 + + (active_index == index and 0.8 or 0) + + (selected_index == index and 0.15 or 0) + if highlight_opacity > 0 then + ass:rect(ax + 2, item_ay - 1, bx - 2, item_by + 1, { + radius = 2, color = options.color_foreground, opacity = highlight_opacity * self.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 = self.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) * 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 = self.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 - 1 + menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title) + + -- Background + ass:rect(ax + 2, title_ay, bx - 2, ay - 3, { + color = options.color_foreground, opacity = opacity * 0.55, radius = 2, + }) + + -- Title + ass:txt(ax + menu.width / 2, title_ay + (self.item_height * 0.5), 5, menu.title, { + size = self.font_size, bold = true, color = options.color_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.color_foreground, opacity = options.menu_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 = 0.4 + 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) + 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, 0.4) + 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.speed_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 = math.min(math.max(speed_drag_current, 0.01), 100) + local drag_dir_up = speed_drag_current > speed_current + + local speed_step_next = speed_current + local speed_drag_diff = math.abs(speed_drag_current - speed_current) + while math.abs(speed_step_next - speed_current) < speed_drag_diff do + speed_step_next = 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.color_background, radius = 2, opacity = opacity * 0.6, + }) + + -- 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 = math.min(math.max(speed_at_center, 0.01), 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.color_foreground, border = 1, border_color = options.color_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.color_foreground .. '\\3c&H' .. options.color_background .. '}') + ass:opacity(options.speed_opacity, opacity) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(half_x, by - 2 - guide_size) + ass:line_to(half_x + guide_size, by - 2) + ass:line_to(half_x - guide_size, by - 2) + ass:draw_stop() + + -- Speed value + local speed_text = (round(state.speed * 100) / 100) .. 'x' + ass:txt(half_x, ay, 8, speed_text, { + size = self.font_size, color = options.color_background_text, + border = 1, border_color = options.color_background, opacity = opacity, + }) + + return ass +end + +--[[ Button ]] + +---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; 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 +function Button:init(id, props) + self.icon = props.icon + self.active = props.active + self.tooltip = props.tooltip + self.foreground = props.foreground or options.color_foreground + self.background = props.background or options.color_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.4), + }) + end + + -- Tooltip on hover + if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) 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 1, border_color = background, opacity = visibility, + }) + + 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 +function CycleButton:init(id, props) + 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] + mp.set_property(self.prop, new_state.value) + end + + self.handle_change = function(name, value) + 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 + + mp.observe_property(self.prop, 'string', self.handle_change) +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, display.height, { + color = options.color_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.color_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 +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.timeline_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) + -- padding serves the purpose of matching cursor to timeline_style=line exactly + local padding = (options.timeline_style == 'line' and self:get_effective_line_width() or 0) / 2 + local progress = math.max(0, math.min((x - self.ax - padding) / (self.width - padding * 2), 1)) + return state.duration * progress +end + +function Timeline:set_from_cursor() + -- add 0.5 to be in the middle of the pixel + mp.commandv('seek', self:get_time_at_x(cursor.x + 0.5), '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_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() + + 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.7, size_min * 2) + local hide_text_ramp = hide_text_below / 2 + local text_opacity = math.max(math.min(size - hide_text_below, hide_text_ramp), 0) / hide_text_ramp + + local spacing = math.max(math.floor((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 + + -- Controls the padding of time on the timeline due to line width. + -- It's a distance from the center of the line to its edge when at the + -- start or end of the timeline. Effectively half of the line width. + local time_padding = 0 + + if is_line then + local minimized_fraction = 1 - (size - size_min) / (self.size_max - size_min) + 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 + local line_width = width_normal - (max_min_width_delta * minimized_fraction) + local current_time_x = (bbx - bax - line_width) * progress + fax = current_time_x + bax + fbx = fax + line_width + if line_width > 2 then time_padding = round(line_width / 2) end + else + fax = bax + fbx = bax + self.width * progress + end + + local time_x = bax + time_padding + local time_width = self.width - time_padding * 2 + local foreground_size = fby - fay + local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping + + -- Background + ass:new_event() + ass:pos(0, 0) + ass:append('{\\blur0\\bord0\\1c&H' .. options.color_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}) + + -- Custom ranges + if state.chapter_ranges ~= nil then + for i, chapter_range in ipairs(state.chapter_ranges) do + for i, range in ipairs(chapter_range.ranges) do + local rax = time_x + time_width * (range['start'].time / state.duration) + local rbx = time_x + time_width * (range['end'].time / state.duration) + -- for 1px chapter size, use the whole size of the bar including padding + local ray = size <= 1 and bay or fay + local rby = size <= 1 and bby or fby + ass:rect(rax, ray, rbx, rby, {color = chapter_range.color, opacity = chapter_range.opacity}) 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'}, - } + -- Chapters + if (options.timeline_chapters ~= 'never' + and (state.chapters ~= nil and #state.chapters > 0 or state.ab_loop_a or state.ab_loop_b) + ) then + local dots = false + -- Defaults are for `lines` + local chapter_width = options.timeline_chapters_width + local chapter_height, chapter_y + if options.timeline_chapters == 'dots' then + dots = true + chapter_height = math.min(chapter_width, (foreground_size / 2) + 1) + chapter_y = fay + chapter_height / 2 + elseif options.timeline_chapters == 'lines' then + chapter_height = size + chapter_y = fay + (chapter_height / 2) + elseif options.timeline_chapters == 'lines-top' then + chapter_height = math.min(self.size_max / 3, size) + chapter_y = fay + (chapter_height / 2) + elseif options.timeline_chapters == 'lines-bottom' then + chapter_height = math.min(self.size_max / 3, size) + chapter_y = fay + size - (chapter_height / 2) + end + + if chapter_height ~= nil then + -- for 1px chapter size, use the whole size of the bar including padding + chapter_height = size <= 1 and foreground_size or chapter_height + local chapter_half_width = chapter_width / 2 + local chapter_half_height = chapter_height / 2 + local function draw_chapter(time) + local chapter_x = time_x + time_width * (time / state.duration) + local ax, bx = chapter_x - chapter_half_width, chapter_x + chapter_half_width + local cx, dx = math.max(ax, fax), math.min(bx, fbx) + local opts = { + color = options.color_foreground, + clip = dots and '\\iclip(' .. foreground_coordinates .. ')' or nil, + opacity = options.timeline_chapters_opacity, + } + + if dots then + -- 0.5 because clipping coordinates are rounded + if (ax - 0.5) < fax or (bx + 0.5) > fbx then + ass:circle(chapter_x, chapter_y, chapter_half_height, opts) + end + if (dx - cx) > 0 then -- intersection + opts.color = options.color_background + opts.clip = '\\clip(' .. foreground_coordinates .. ')' + ass:circle(chapter_x, chapter_y, chapter_half_height, opts) + end + else + ax, bx = round(ax), round(bx) + local ay, by = chapter_y - chapter_half_height, chapter_y + chapter_half_height + if ax < fax then --left of progress + ass:rect(ax, ay, math.min(bx, fax), by, opts) + end + if bx > fbx then --right of progress + ass:rect(math.max(ax, fbx), ay, bx, by, opts) + end + if (dx - cx) > 0 then --intersection + opts.color = options.color_background + ass:rect(cx, ay, dx, by, opts) + end + end + end + + if state.chapters ~= nil then + for i, chapter in ipairs(state.chapters) do + if not chapter._uosc_used_as_range_point then draw_chapter(chapter.time) end + end + end + + if state.ab_loop_a and state.ab_loop_a > 0 then draw_chapter(state.ab_loop_a) end + if state.ab_loop_b and state.ab_loop_b > 0 then draw_chapter(state.ab_loop_b) end + end end -end)() + + -- Cached ranges + if config.cached_ranges and state.cached_ranges then + local range_height = math.max(math.floor(math.min(self.size_max / 10, foreground_size / 2)), 1) + local range_ay = fby - range_height + + for _, range in ipairs(state.cached_ranges) do + local range_start = math.max(type(range['start']) == 'number' and range['start'] or 0.000001, 0.000001) + local range_end = math.min(type(range['end']) and range['end'] or state.duration, state.duration) + ass:rect( + time_x + time_width * (range_start / state.duration), range_ay, + time_x + time_width * (range_end / state.duration), range_ay + range_height, + {color = config.cached_ranges.color, opacity = config.cached_ranges.opacity} + ) + end + + -- Visualize padded time area limits + if time_padding > 0 then + local notch_ay = math.max(range_ay - 2, fay) + local opts = {color = config.cached_ranges.color, opacity = options.timeline_opacity} + ass:rect(time_x, notch_ay, time_x + 1, bby, opts) + ass:rect(time_x + time_width - 1, notch_ay, time_x + time_width, bby, opts) + end + end + + -- Time values + if text_opacity > 0 then + local opts = {size = self.font_size, opacity = math.min(options.timeline_opacity + 0.1, 1) * text_opacity} + + -- Elapsed time + if state.time_human then + local elapsed_x = bax + spacing + local elapsed_y = fay + (size / 2) + opts.color = options.color_foreground_text + opts.clip = '\\clip(' .. foreground_coordinates .. ')' + ass:txt(elapsed_x, elapsed_y, 4, state.time_human, opts) + opts.color = options.color_background_text + opts.clip = '\\iclip(' .. foreground_coordinates .. ')' + ass:txt(elapsed_x, elapsed_y, 4, state.time_human, opts) + end + + -- End time + if state.duration_or_remaining_time_human then + local end_x = bbx - spacing + local end_y = fay + (size / 2) + opts.color = options.color_foreground_text + opts.clip = '\\clip(' .. foreground_coordinates .. ')' + ass:txt(end_x, end_y, 6, state.duration_or_remaining_time_human, opts) + opts.color = options.color_background_text + opts.clip = '\\iclip(' .. foreground_coordinates .. ')' + ass:txt(end_x, end_y, 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 + -- add 0.5 to be in the middle of the pixel + local hovered_seconds = self:get_time_at_x(cursor.x + 0.5) + local chapter_title, chapter_title_width = nil, nil + + if (options.timeline_chapters ~= 'never' and state.chapters) then + for i = #state.chapters, 1, -1 do + local chapter = state.chapters[i] + if hovered_seconds >= chapter.time then + if not chapter.is_end_only then + chapter_title = chapter.title_wrapped + chapter_title_width = chapter.title_wrapped_width + end + break + end + end + end + + -- 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.color_background or options.color_foreground + local line = {ax = cursor.x, ay = fay, bx = cursor.x + 1, by = fby} + ass:rect(line.ax, line.ay, line.bx, line.by, {color = color, opacity = 0.2}) + + -- Timestamp + ass:tooltip(line, format_time(hovered_seconds), {size = self.font_size, offset = 2}) + + -- Chapter title + if chapter_title then + ass:tooltip(line, chapter_title, { + offset = 2 + self.font_size * 1.4, size = self.font_size, bold = true, + text_length_override = chapter_title_width, + }) + end + end + + return ass +end + +--[[ TopBarButton ]] + +---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string} + +---@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.icon = props.icon + self.background = props.background + self.command = props.command +end + +function TopBarButton:on_mbtn_left_down() mp.command(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 = 1}) + + 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_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 + + -- 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 = 'cycle window-maximized'}), + 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 + elseif options.top_bar == 'always' then + self.enabled = true + else + self.enabled = false + 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)) + 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.media_title or state.playlist_count > 1) then + local max_bx = self.title_bx - self.spacing + local text = state.media_title or 'n/a' + if state.playlist_count > 1 then + text = string.format('%d/%d - ', state.playlist_pos, state.playlist_count) .. text + end + + -- Background + local padding = self.font_size / 2 + local bg_margin = math.floor((self.size - self.font_size) / 4) + local bg_ax = self.ax + bg_margin + local bg_bx = math.min(max_bx, self.ax + text_width_estimate(text, self.font_size) + padding * 2) + ass:rect(bg_ax, self.ay + bg_margin, bg_bx, self.by - bg_margin, { + color = options.color_background, opacity = visibility * 0.8, radius = 2, + }) + + -- Text + ass:txt(bg_ax + padding, self.ay + (self.size / 2), 4, text, { + size = self.font_size, wrap = 2, color = 'FFFFFF', border = 1, border_color = '000000', opacity = visibility, + clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by), + }) + 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;} + +---@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[] + self.controls = {} + self:serialize() +end + +function Controls:serialize() + local shorthands = { + menu = 'command:menu:script-binding uosc/menu?Menu', + subtitles = 'command:subtitles:script-binding uosc/subtitles?Subtitles', + audio = 'command:audiotrack:script-binding uosc/audio?Audio', + ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device', + video = 'command:theaters:script-binding uosc/video?Video', + playlist = 'command:list_alt:script-binding uosc/playlist?Playlist', + chapters = 'command:bookmarks:script-binding uosc/chapters?Chapters', + ['stream-quality'] = 'command:deblur: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 configs + 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 == '<' then in_disposition = true + elseif c == '>' 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 + + -- Filter out based on disposition + items = itable_filter(items, function(item) + if item.disposition == '' then return true end + local dispositions = split(item.disposition, ' *, *') + for _, disposition in ipairs(dispositions) do + local value = disposition:sub(1, 1) ~= '!' + local name = not value and disposition:sub(2) or disposition + local prop = name == 'has_audio' and name or 'is_' .. name + if state[prop] ~= value then return false end + end + return true + end) + + -- 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, ' *%? *') + config = config_tooltip[1] + local tooltip = config_tooltip[2] + local parts = split(config, ' *: *') + local kind, params = parts[1], itable_slice(parts, 2) + + -- Convert toggles into cycles + if kind == 'toggle' then + kind = 'cycle' + params[#params + 1] = 'no/yes!' + end + + if kind == 'space' then + self.controls[#self.controls + 1] = {kind = kind, sizing = 'space'} + elseif kind == 'gap' then + self.controls[#self.controls + 1] = { + kind = kind, 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, + }) + self.controls[#self.controls + 1] = { + kind = kind, element = element, sizing = 'static', scale = 1, ratio = 1, + } + 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, + }) + self.controls[#self.controls + 1] = { + kind = kind, element = element, sizing = 'static', scale = 1, ratio = 1, + } + end + elseif kind == 'speed' then + if not Elements.speed then + local element = Speed:new({anchor_id = 'controls'}) + self.controls[#self.controls + 1] = { + kind = kind, 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 + end + end + + self:update_dimensions() +end + +function Controls:clean_controls() + for _, control in ipairs(self.controls) do + if control.element then Elements:remove(control.element) end + end + self.controls = {} + request_render() +end + +function Controls:get_visibility() + return (Elements.speed and Elements.speed.dragging) and 1 or Elements.timeline.proximity_raw == 0 + 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.controls) 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.controls - 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.controls) 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.controls / 2 + 0.1) + for a = 0, #self.controls - 1, 1 do + i = i + (a * (a % 2 == 0 and 1 or -1)) + local control = self.controls[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.controls) 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 + + request_render() +end + +function Controls:on_dispositions() + self:clean_controls() + self:serialize() +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.volume_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 +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', math.max(math.min(volume, state.volume_max), 0)) +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) +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 width, height = self.bx - self.ax, self.by - self.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 + + -- Background bar coordinates + local bax, bay, bbx, bby = self.ax, self.ay, self.bx, self.by + + -- Foreground bar coordinates + local height_without_border = height - (options.volume_border * 2) + local fax = self.ax + options.volume_border + local fay = self.ay + (height_without_border * (1 - math.min(state.volume / state.volume_max, 1))) + + options.volume_border + local fbx = self.bx - options.volume_border + local fby = self.by - options.volume_border + + -- Draws a rectangle with nudge at requested position + ---@param ax number + ---@param ay number + ---@param bx number + ---@param by number + function make_nudged_path(ax, ay, bx, by) + local fg_path = assdraw.ass_new() + fg_path:move_to(bx, by) + fg_path:line_to(ax, by) + local nudge_bottom_y = nudge_y + nudge_size + if ay <= nudge_bottom_y then + fg_path:line_to(ax, math.min(nudge_bottom_y)) + if ay <= nudge_y then + fg_path:line_to((ax + nudge_size), nudge_y) + local nudge_top_y = nudge_y - nudge_size + if ay <= nudge_top_y then + fg_path:line_to(ax, nudge_top_y) + fg_path:line_to(ax, ay) + fg_path:line_to(bx, ay) + fg_path:line_to(bx, nudge_top_y) + else + local triangle_side = ay - nudge_top_y + fg_path:line_to((ax + triangle_side), ay) + fg_path:line_to((bx - triangle_side), ay) + end + fg_path:line_to((bx - nudge_size), nudge_y) + else + local triangle_side = nudge_bottom_y - ay + fg_path:line_to((ax + triangle_side), ay) + fg_path:line_to((bx - triangle_side), ay) + end + fg_path:line_to(bx, nudge_bottom_y) + else + fg_path:line_to(ax, ay) + fg_path:line_to(bx, ay) + end + fg_path:line_to(bx, by) + return fg_path + end + + -- FG & BG paths + local fg_path = make_nudged_path(fax, fay, fbx, fby) + local bg_path = make_nudged_path(bax, bay, bbx, bby) + + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H' .. options.color_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.color_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.volume_font_scale) + local opacity = math.min(options.volume_opacity + 0.1, 1) * visibility + if fay < self.by - self.spacing then + ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, { + size = font_size, color = options.color_foreground_text, opacity = opacity, + clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + if fay > 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.color_background_text, opacity = opacity, + clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + + -- Disabled stripes for no audio + if not state.has_audio then + -- Create 100 foreground clip path + local f100ax, f100ay = self.ax + options.volume_border, self.ay + options.volume_border + local f100bx, f100by = self.bx - options.volume_border, self.by - options.volume_border + local fg_100_path = make_nudged_path(f100ax, f100ay, f100bx, f100by) + + -- Render stripes + local stripe_height = 12 + local skew_height = stripe_height + local colors = {'000000', 'ffffff'} + + for c, color in ipairs(colors) do + local stripe_y = self.ay + stripe_height * (c - 1) + + ass:new_event() + ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. color .. + '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')}') + ass:opacity(0.15 * opacity) + ass:pos(0, 0) + ass:draw_start() + + while stripe_y - skew_height < self.by do + ass:move_to(self.ax, stripe_y) + ass:line_to(self.bx, stripe_y - skew_height) + ass:line_to(self.bx, stripe_y - skew_height + stripe_height) + ass:line_to(self.ax, stripe_y + stripe_height) + stripe_y = stripe_y + stripe_height * #colors + end + + ass:draw_stop() + end + 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: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 padding_top = top_bar.enabled and top_bar.size or 0 + local padding_bottom = (timeline.enabled and timeline.size_max or 0) + + (controls and controls.enabled and controls.by - controls.ay or 0) + local available_height = display.height - padding_top - padding_bottom + 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 = padding_top + 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 + +--[[ 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() -- EVENT HANDLERS @@ -3397,7 +3598,7 @@ function update_cursor_position() cursor.x = cursor.x / dpi_scale cursor.y = cursor.y / dpi_scale - update_proximities() + Elements:update_proximities() request_render() end @@ -3413,14 +3614,13 @@ function handle_mouse_leave() end cursor.hidden = true - update_proximities() + Elements:update_proximities() Elements:trigger('global_mouse_leave') end function handle_mouse_enter() cursor.hidden = false update_cursor_position() - tween_element_stop(state) Elements:trigger('global_mouse_enter') end @@ -3433,7 +3633,7 @@ function handle_mouse_move() end update_cursor_position() - Elements:trigger('global_mouse_move') + Elements:proximity_trigger('mouse_move') request_render() -- Restart timer that hides UI when mouse is autohidden @@ -3448,7 +3648,7 @@ function navigate_directory(direction) if not path or is_protocol(path) then return end - local next_file = get_adjacent_file(path, direction, options.media_types) + local next_file = get_adjacent_file(path, direction, config.media_types) if next_file then mp.commandv('loadfile', utils.join_path(serialize_path(path).dirname, next_file)) @@ -3460,21 +3660,21 @@ function load_file_in_current_directory(index) if not path or is_protocol(path) then return end - local dirname = serialize_path(path).dirname - local files = get_files_in_directory(dirname, options.media_types) + local serialized = serialize_path(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 not files then return end + if index < 0 then index = #files + index + 1 end - if files[index] then - mp.commandv('loadfile', utils.join_path(dirname, files[index])) + 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 + if fps then state.render_delay = 1 / fps end end function observe_display_fps(name, fps) @@ -3485,46 +3685,53 @@ function observe_display_fps(name, fps) end end --- MENUS +--[[ MENUS ]] -function toggle_menu_with_items(items, menu_options) - menu_options = menu_options or {} - menu_options.type = 'menu' - - -- preselect 1st item - if not menu_options.selected_index then menu_options.selected_index = 1 end - - if menu:is_open('menu') then - menu:close() - elseif items then - menu:open(items, function(command) mp.command(command) end, menu_options) - end +---@param data MenuData +---@param submenu_id? string ID of submenu to pre-open. +function open_command_menu(data, submenu_id) + 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) + if submenu_id then menu:activate_submenu(submenu_id) end + return menu end ----@param options {type: string; title: string; list_prop: string; list_serializer: fun(name: string, value: any): MenuItem[]; active_prop?: string; active_index_serializer: fun(name: string, value: any): integer; on_select: fun(value: any)} +---@param submenu_id? string Id of submenu to pre-open +function toggle_menu_with_items(submenu_id) + if Menu:is_open('menu') then Menu:close() + else open_command_menu({type = 'menu', items = config.menu_items}, submenu_id) end +end + +---@param options {type: string; title: string; list_prop: string; list_serializer: fun(name: string, value: any): MenuDataItem[]; active_prop?: string; active_index_serializer: fun(name: string, value: any): 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 + 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 menu:is_open(options.type) then - local items, active_index, default_selected_index = options.list_serializer(name, value) - Elements.menu:update({items = items, active_index = active_index}) - end + 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 menu:is_open(options.type) then - Elements.menu:activate_index(options.active_index_serializer(name, value)) - end + if ignore_initial_active then ignore_initial = false + else menu:activate_index(options.active_index_serializer(name, value)) end end + local initial_items = 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:open({}, options.on_select, { - type = options.type, - title = options.title, + menu = Menu:open({type = options.type, title = options.title, items = initial_items}, options.on_select, { on_open = function() mp.observe_property(options.list_prop, 'native', handle_list_prop_change) if options.active_prop then @@ -3542,24 +3749,24 @@ end function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command) local function serialize_tracklist(_, tracklist) local items = {} - local active_index = nil - local disabled_item_index = nil if load_command then - items[#items + 1] = {title = 'Load', bold = true, hint = 'open file', value = '{load}'} + items[#items + 1] = { + title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true + } end + local first_item_index = #items + 1 + -- 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 - items[#items + 1] = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil} - disabled_item_index = #items + items[#items + 1] = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true} end - local static_items_count = #items for _, track in ipairs(tracklist) do if track.type == track_type then local hint_vals = { @@ -3584,17 +3791,13 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_ title = (track.title and track.title or 'Track ' .. track.id), hint = table.concat(hint_vals_filtered, ', '), value = track.id, + selected = first_item_index == #items + 1 or track.selected, + active = track.selected, } - - if track.selected then active_index = #items end end end - -- Preselect disabled item if active index is missing - if not active_index then active_index = disabled_item_index end - - -- items, active index, default selected index when active is nil - return items, active_index, static_items_count + 1 + return items end local function selection_handler(value) @@ -3619,15 +3822,15 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_ }) end ----@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string} +---@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 menu_options NavigationMenuOptions -function open_file_navigation_menu(directory_path, handle_select, menu_options) +---@param opts NavigationMenuOptions +function open_file_navigation_menu(directory_path, handle_select, opts) directory = serialize_path(directory_path) - menu_options = menu_options or {} + opts = opts or {} if not directory then msg.error('Couldn\'t serialize path "' .. directory_path .. '.') @@ -3635,7 +3838,7 @@ function open_file_navigation_menu(directory_path, handle_select, menu_options) end local directories, dirs_error = utils.readdir(directory.path, 'dirs') - local files, files_error = get_files_in_directory(directory.path, menu_options.allowed_types) + local 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 @@ -3652,16 +3855,17 @@ function open_file_navigation_menu(directory_path, handle_select, menu_options) if is_root then if state.os == 'windows' then - items[#items + 1] = {title = '..', hint = 'Drives', value = {is_drives = true, is_to_parent = true}} + 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} + items[#items + 1] = {title = '..', hint = 'parent dir', value = serialized, separator = true} end - -- Index where actual items start local items_start_index = #items + 1 for _, dir in ipairs(directories) do @@ -3680,37 +3884,27 @@ function open_file_navigation_menu(directory_path, handle_select, menu_options) end end - menu_options.active_index = nil - for index, item in ipairs(items) do if not item.value.is_to_parent then - if menu_options.active_path == item.value.path then - menu_options.active_index = index + 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 menu_options.selected_path == item.value.path then - menu_options.selected_index = index - end + if opts.selected_path == item.value.path then item.selected = true end end end - if menu_options.selected_index == nil then - menu_options.selected_index = menu_options.active_index or math.min(items_start_index, #items) - end + local menu_data = { + type = opts.type, title = opts.title or directory.basename .. '/', items = items, + on_open = opts.on_open, on_close = opts.on_close, + } - local inherit_title = false - if menu_options.title == nil then - menu_options.title = directory.basename .. '/' - else - inherit_title = true - end - - menu:open(items, function(path) + return Menu:open(menu_data, function(path) local inheritable_options = { - type = menu_options.type, - title = inherit_title and menu_options.title or nil, - allowed_types = menu_options.allowed_types, - active_path = menu_options.active_path, + type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path, } if path.is_drives then @@ -3729,16 +3923,15 @@ function open_file_navigation_menu(directory_path, handle_select, menu_options) open_file_navigation_menu(path.path, handle_select, inheritable_options) else handle_select(path.path) - menu:close() end - end, menu_options) + end) end -- Opens a file navigation menu with Windows drives as items. ---@param handle_select fun(path: string): nil ----@param menu_options? NavigationMenuOptions -function open_drives_menu(handle_select, menu_options) - menu_options = menu_options or {} +---@param opts? NavigationMenuOptions +function open_drives_menu(handle_select, opts) + opts = opts or {} local process = mp.command_native({ name = 'subprocess', capture_stdout = true, @@ -3752,49 +3945,54 @@ function open_drives_menu(handle_select, menu_options) local drive = string.match(value, 'Name=([A-Z]:)') if drive then local drive_path = normalize_path(drive) - items[#items + 1] = {title = drive, hint = 'Drive', value = drive_path} - if menu_options.selected_path == drive_path then - menu_options.selected_index = #items - end + 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 - if not menu_options.title then - menu_options.title = 'Drives' - end - - menu:open(items, handle_select, menu_options) + return Menu:open({type = opts.type, title = opts.title or 'Drives', items = items}, handle_select) end --- VALUE SERIALIZATION/NORMALIZATION +--[[ HOOKS]] -options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) -options.timeline_chapters = itable_index_of({'dots', 'lines', 'lines-top', 'lines-bottom'}, options.timeline_chapters) - and - options.timeline_chapters or 'never' -options.media_types = split(options.media_types, ' *, *') -options.subtitle_types = split(options.subtitle_types, ' *, *') -options.stream_quality_options = split(options.stream_quality_options, ' *, *') -options.timeline_cached_ranges = (function() - if options.timeline_cached_ranges == '' or options.timeline_cached_ranges == 'no' then return nil end - local parts = split(options.timeline_cached_ranges, ':') - return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil -end)() -for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do - local option_name = name .. '_persistency' - local flags = {} - for _, state in ipairs(split(options[option_name], ' *, *')) do - flags[state] = true - end - - ---@diagnostic disable-next-line: assign-type-mismatch - options[option_name] = flags +-- 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') --- HOOKS mp.register_event('file-loaded', parse_chapters) mp.observe_property('playback-time', 'number', create_state_setter('time', update_human_times)) mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times)) @@ -3850,143 +4048,6 @@ end) mp.observe_property('display-fps', 'native', observe_display_fps) mp.observe_property('estimated-display-fps', 'native', update_render_delay) --- CONTROLS - --- Mouse movement key binds -local base_keybinds = { - {'mouse_move', handle_mouse_move}, - {'mouse_leave', handle_mouse_leave}, - {'mouse_enter', handle_mouse_enter}, -} -if options.pause_on_click_shorter_than > 0 then - -- Cycles pause when click is shorter than `options.pause_on_click_shorter_than` - -- while filtering out double clicks. - local duration_seconds = options.pause_on_click_shorter_than / 1000 - local last_down_event; - local click_timer = mp.add_timeout(duration_seconds, function() - mp.command('cycle pause') - end); - click_timer:kill() - base_keybinds[#base_keybinds + 1] = {'mbtn_left', function() - if mp.get_time() - last_down_event < duration_seconds then - click_timer:resume() - end - end, function() - if click_timer:is_enabled() then - click_timer:kill() - last_down_event = 0 - else - last_down_event = mp.get_time() - end - end, - } -end -mp.set_key_bindings(base_keybinds, 'mouse_movement', 'force') -mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor') - --- Context based key bind groups - -forced_key_bindings = (function() - local function create_mouse_event_dispatcher(name) - return function(...) - for _, element in Elements:ipairs() do - if element.proximity_raw == 0 then - element:trigger(name, ...) - end - element:trigger('global_' .. name, ...) - end - end - end - - mp.set_key_bindings({ - {'mbtn_left', create_mouse_event_dispatcher('mbtn_left_up'), create_mouse_event_dispatcher('mbtn_left_down')}, - {'mbtn_left_dbl', 'ignore'}, - }, 'mbtn_left', 'force') - mp.set_key_bindings({ - {'wheel_up', create_mouse_event_dispatcher('wheel_up')}, - {'wheel_down', create_mouse_event_dispatcher('wheel_down')}, - }, 'wheel', 'force') - - local groups = {} - for _, group in ipairs({'mbtn_left', 'wheel'}) do - groups[group] = { - is_enabled = false, - enable = function(this) - if this.is_enabled then return end - this.is_enabled = true - mp.enable_key_bindings(group) - end, - disable = function(this) - if not this.is_enabled then return end - this.is_enabled = false - mp.disable_key_bindings(group) - end, - } - end - return groups -end)() - --- MESSAGE HANDLERS - -mp.register_script_message('get-version', function(script) - mp.commandv('script-message-to', script, 'uosc-version', state.version) -end) -mp.register_script_message('show-menu', function(json) - local menu_config = utils.parse_json(json) - - if type(menu_config) ~= 'table' or type(menu_config.items) ~= 'table' then - msg.error('show-menu: received json didn\'t produce a table with menu configuration') - return - end - - function run_command(value) - if type(value) == 'string' then - mp.command(value) - else - ---@diagnostic disable-next-line: deprecated - mp.commandv((unpack or table.unpack)(value)) - end - end - - if menu_config.type ~= nil and menu:is_open(menu_config.type) then - menu:close() - return - end - - Menu:open(menu_config.items, run_command, { - type = menu_config.type, - title = menu_config.title, - selected_index = menu_config.selected_index or menu_config.active_index or (#menu_config.items > 0 and 1 or nil), - active_index = menu_config.active_index, - }) -end) -mp.register_script_message('show-submenu', function(name) - local path = split(name, ' *>+ *') - local items = state.context_menu_items - local last_menu_title = nil - - if not items or #items < 1 then - msg.error('Can\'t find submenu, context menu is empty.') - return - end - - while #path > 0 do - local menu_title = path[1] - last_menu_title = menu_title - path = itable_slice(path, 2) - local _, submenu_item = itable_find(items, function(_, item) return item.title == menu_title end) - - if not submenu_item then - msg.error('Can\'t find submenu: ' .. menu_title) - return - end - - items = submenu_item.items or {} - end - - if items then toggle_menu_with_items(items, {title = last_menu_title, selected_index = 1}) end -end) - -- KEY BINDABLE FEATURES mp.add_key_binding(nil, 'peek-timeline', function() @@ -4024,17 +4085,16 @@ end) mp.add_key_binding(nil, 'decide-pause-indicator', function() Elements.pause_indicator:decide() end) -function menu_key_binding() toggle_menu_with_items(state.context_menu_items) end -mp.add_key_binding(nil, 'menu', menu_key_binding) +mp.add_key_binding(nil, 'menu', function() toggle_menu_with_items() end) local track_loaders = { - {name = 'subtitles', prop = 'sub', extensions = options.subtitle_types --[[@as table]]}, - {name = 'audio', prop = 'audio'}, - {name = 'video', prop = 'video'}, + {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 + if Menu:is_open(menu_type) then Menu:close() return end local path = mp.get_property_native('path') --[[@as string|nil|false]] if path then @@ -4051,7 +4111,7 @@ for _, loader in ipairs(track_loaders) do open_file_navigation_menu( path, function(path) mp.commandv(loader.prop .. '-add', path) end, - {type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.extensions} + {type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types} ) end) end @@ -4108,7 +4168,7 @@ mp.add_key_binding(nil, 'chapters', create_self_updating_menu_opener({ -- than current playing position. local position = playback_time if not position then return nil end - local items = Elements.menu.items + local items = Menu.current.items for index = #items, 1, -1 do if position >= items[index].value then return index end end @@ -4137,22 +4197,17 @@ mp.add_key_binding(nil, 'show-in-directory', function() end end) mp.add_key_binding(nil, 'stream-quality', function() - if menu:is_open('stream-quality') then menu:close() return end + if Menu:is_open('stream-quality') then Menu:close() return end local ytdl_format = mp.get_property_native('ytdl-format') - local active_index = nil - local formats = {} + local items = {} - for index, height in ipairs(options.stream_quality_options) do + for _, height in ipairs(config.stream_quality_options) do local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']' - formats[#formats + 1] = { - title = height .. 'p', - value = format, - } - if format == ytdl_format then active_index = index end + items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format} end - menu:open(formats, function(format) + 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 @@ -4177,14 +4232,10 @@ mp.add_key_binding(nil, 'stream-quality', function() end mp.register_event('file-loaded', seeker) end - end, { - type = 'stream-quality', - title = 'Stream quality', - active_index = active_index, - }) + end) end) mp.add_key_binding(nil, 'open-file', function() - if menu:is_open('open-file') then menu:close() return end + if Menu:is_open('open-file') then Menu:close() return end local path = mp.get_property_native('path') local directory @@ -4209,12 +4260,11 @@ mp.add_key_binding(nil, 'open-file', function() return end - -- Update selected file in directory navigation menu + -- Update active file in directory navigation menu local function handle_file_loaded() - if menu:is_open('open-file') then + if Menu:is_open('open-file') then local path = normalize_path(mp.get_property_native('path')) Elements.menu:activate_value(path) - Elements.menu:select_value(path) end end @@ -4223,7 +4273,7 @@ mp.add_key_binding(nil, 'open-file', function() function(path) mp.commandv('loadfile', path) end, { type = 'open-file', - allowed_types = options.media_types --[[@as table]] , + 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, @@ -4272,32 +4322,24 @@ mp.add_key_binding(nil, 'first-file', function() load_file_in_current_directory( mp.add_key_binding(nil, 'last-file', function() load_file_in_current_directory(-1) end) mp.add_key_binding(nil, 'delete-file-next', function() local playlist_count = mp.get_property_native('playlist-count') - local next_file = nil - local path = mp.get_property_native('path') local is_local_file = path and not is_protocol(path) if is_local_file then path = normalize_path(path) - - if menu:is_open('open-file') then - Elements.menu:delete_value(path) - end + if Menu:is_open('open-file') then Elements.menu:delete_value(path) end end if playlist_count > 1 then mp.commandv('playlist-remove', 'current') else if is_local_file then - next_file = get_adjacent_file(path, 'forward', options.media_types) + next_file = get_adjacent_file(path, 'forward', config.media_types) end - if next_file then - mp.commandv('loadfile', next_file) - else - mp.commandv('stop') - end + if next_file then mp.commandv('loadfile', next_file) + else mp.commandv('stop') end end if is_local_file then delete_file(path) end @@ -4353,3 +4395,19 @@ mp.add_key_binding(nil, 'open-config-directory', function() msg.error('Couldn\'t serialize config path "' .. config_path .. '".') end end) + +-- MESSAGE HANDLERS + +mp.register_script_message('show-submenu', toggle_menu_with_items) +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) end + end +end)