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