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