--[[ uosc 2.9.0 - 2020-May-11 | https://github.com/darsain/uosc Minimalist cursor proximity based UI for MPV player. uosc replaces the default osc UI, so that has to be disabled first. Place these options into your `mpv.conf` file: ``` # required so that the 2 UIs don't fight each other osc=no # uosc provides its own seeking/volume indicators, so you also don't need this osd-bar=no # uosc will draw its own window controls if you disable window border border=no ``` Options go in `script-opts/uosc.conf`. Defaults: ``` # timeline size when fully retracted, 0 will hide it completely timeline_size_min=2 # timeline size when fully expanded, in pixels, 0 to disable timeline_size_max=40 # same as ^ but when in fullscreen timeline_size_min_fullscreen=0 timeline_size_max_fullscreen=60 # same thing as calling toggle-progress command once on startup timeline_start_hidden=no # timeline opacity timeline_opacity=0.8 # top (and bottom in no-border mode) border of background color to help visually # separate elapsed bar from a video of similar color or desktop background timeline_border=1 # when scrolling above timeline, wheel will seek by this amount of seconds timeline_step=5 # display seekable buffered ranges for streaming videos, syntax `color:opacity`, # color is an BBGGRR hex code, set to `none` to disable timeline_cached_ranges=345433:0.5 # floating number font scale adjustment timeline_font_scale=1 # briefly show timeline on external changes (e.g. seeking with a hotkey) timeline_flash=yes # timeline chapters style: none, dots, lines, lines-top, lines-bottom chapters=dots chapters_opacity=0.3 # where to display volume controls: none, left, right volume=right volume_size=40 volume_size_fullscreen=60 volume_opacity=0.8 volume_border=1 volume_step=1 volume_font_scale=1 volume_flash=yes # playback speed widget: mouse drag or wheel to change, click to reset speed=no speed_size=46 speed_size_fullscreen=68 speed_opacity=1 speed_step=0.1 speed_font_scale=1 speed_flash=yes # controls all menus, such as context menu, subtitle loader/selector, etc menu_item_height=36 menu_item_height_fullscreen=50 menu_wasd_navigation=no menu_hjkl_navigation=no menu_opacity=0.8 menu_font_scale=1 # top bar with window controls and media title shown only in no-border mode top_bar_size=40 top_bar_size_fullscreen=46 top_bar_controls=yes top_bar_title=yes # pause video on clicks shorter than this number of milliseconds, 0 to disable pause_on_click_shorter_than=0 # for how long in milliseconds to show elements they're it's being flashed flash_duration=400 # distances in pixels below which elements are fully faded in/out proximity_in=40 proximity_out=120 # BBGGRR - BLUE GREEN RED hex color codes color_foreground=ffffff color_foreground_text=000000 color_background=000000 color_background_text=ffffff # use bold font weight throughout the whole UI font_bold=no # hide UI when mpv autohides the cursor autohide=no # can be: none, flash, static pause_indicator=flash # load first file when calling next on a last file in a directory and vice versa directory_navigation_loops=no # file types to look for when navigating media files media_types=3gp,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 # file types to look for when loading external subtitles subtitle_types=aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt # used to approximate text width # if you are using some wide font and see a lot of right side clipping in menus, # try bumping this up font_height_to_letter_width_ratio=0.5 # `chapter_ranges` lets you transform chapter indicators into range indicators. # # Chapter range definition syntax: # ``` # start_patternend_pattern # ``` # # Multiple start and end patterns can be defined by separating them with `|`: # ``` # p1|pNp1|pN # ``` # # Multiple chapter ranges can be defined by separating them with comma: # # chapter_ranges=range1,rangeN # # One of `start_pattern`s can be a custom keyword `{bof}` that will match # beginning of file when it makes sense. # # One of `end_pattern`s can be a custom keyword `{eof}` that will match end of # file when it makes sense. # # Patterns are lua patterns (http://lua-users.org/wiki/PatternsTutorial). # They only need to occur in a title, not match it completely. # Matching is case insensitive. # # `color` is a `bbggrr` hexadecimal color code. # `opacity` is a float number from 0 to 1. # # Examples: # # Display skippable youtube video sponsor blocks from https://github.com/po5/mpv_sponsorblock # ``` # chapter_ranges=sponsor start<3535a5:0.5>sponsor end # ``` # # Display anime openings and endings as ranges: # ``` # chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof} # ``` chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end ``` Available keybindings (place into `input.conf`): ``` Key script-binding uosc/peek-timeline Key script-binding uosc/toggle-progress Key script-binding uosc/menu Key script-binding uosc/load-subtitles Key script-binding uosc/subtitles Key script-binding uosc/audio Key script-binding uosc/video Key script-binding uosc/playlist Key script-binding uosc/chapters Key script-binding uosc/open-file Key script-binding uosc/next Key script-binding uosc/prev Key script-binding uosc/first Key script-binding uosc/last Key script-binding uosc/next-file Key script-binding uosc/prev-file Key script-binding uosc/first-file Key script-binding uosc/last-file Key script-binding uosc/delete-file-next Key script-binding uosc/delete-file-quit Key script-binding uosc/show-in-directory Key script-binding uosc/open-config-directory ``` ]] 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_size_min = 2, timeline_size_max = 40, timeline_size_min_fullscreen = 0, timeline_size_max_fullscreen = 60, timeline_start_hidden = false, timeline_opacity = 0.8, timeline_border = 1, timeline_step = 5, timeline_cached_ranges = '345433:0.5', timeline_font_scale = 1, timeline_flash = true, chapters = 'dots', chapters_opacity = 0.3, volume = 'right', volume_size = 40, volume_size_fullscreen = 60, volume_opacity = 0.8, volume_border = 1, volume_step = 1, volume_font_scale = 1, volume_flash = true, speed = false, speed_size = 46, speed_size_fullscreen = 68, speed_opacity = 1, speed_step = 0.1, speed_font_scale = 1, speed_flash = true, menu_item_height = 36, menu_item_height_fullscreen = 50, menu_wasd_navigation = false, menu_hjkl_navigation = false, menu_opacity = 0.8, menu_font_scale = 1, top_bar_size = 40, top_bar_size_fullscreen = 46, top_bar_controls = true, top_bar_title = true, pause_on_click_shorter_than = 0, flash_duration = 400, proximity_in = 40, proximity_out = 120, color_foreground = 'ffffff', color_foreground_text = '000000', color_background = '000000', color_background_text = 'ffffff', font_bold = false, autohide = false, pause_indicator = 'flash', directory_navigation_loops = false, media_types = '3gp,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, chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end' } opt.read_options(options, 'uosc') local config = { render_delay = 0.03, -- sets max rendering frequency font = mp.get_property('options/osd-font'), menu_parent_opacity = 0.4, menu_min_width = 260 } 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 = '', duration = nil, position = nil, pause = false, chapters = nil, chapter_ranges = nil, fullscreen = mp.get_property_native('fullscreen'), maximized = mp.get_property_native('window-maximized'), render_timer = nil, render_last_time = 0, volume = nil, volume_max = nil, mute = 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 } 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(letters, font_size) return letters and letters * font_size * options.font_height_to_letter_width_ratio or 0 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 -- Use proper slashes if state.os == 'windows' then 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 path = normalize_path(path) local parts = split(path, '[\\/]+') if parts[#parts] == '' then table.remove(parts, #parts) end -- remove trailing separator local basename = parts and parts[#parts] or 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 = path:sub(-1) == ':' and state.os == 'windows' and path .. '\\' or 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) 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 -- 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 -- Element --[[ Signature: { -- enables capturing button groups for this element captures = {mouse_buttons = true, wheel = true}, -- 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 = { captures = nil, 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 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:has(name) return self[name] ~= nil end function Elements:ipairs() return ipairs(Elements.itable) end function Elements:pairs(elements) return pairs(self) 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 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({ captures = {mouse_buttons = true}, 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, scroll_step = nil, scroll_height = nil, scroll_y = 0, opacity = 0, relative_parent_opacity = 0.4, items = items, active_item = nil, selected_item = 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 this.selected_item = this.active_item -- Set initial dimensions this:on_display_resize() -- Scroll to active item this:scroll_to_item(this.active_item) -- 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 - config.menu_parent_opacity) * pos)) end, function() menu.transition = nil update_proximities() end) end, destroy = function(this) request_render() end, on_display_resize = function(this) this.item_height = (state.fullscreen or state.maximized) 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.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(items) do local item_text_length = ((item.title and item.title:len() or 0) + (item.hint and item.hint:len() or 0)) local spacings_in_item = item.hint and 3 or 2 local estimated_width = text_width_estimate(item_text_length, this.font_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_length = this.title and this.title:len() or 0 local estimated_menu_title_width = text_width_estimate(menu_title_length, 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. this.width = round(math.min(math.max(estimated_max_width, config.menu_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 * #items) - this.item_spacing, max_height) this.scroll_height = math.max( (this.scroll_step * #this.items) - this.height - this.item_spacing, 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_resize() end end, set_items = function(this, items, props) this.items = items this.selected_item = nil this.active_item = nil if props then for key, value in pairs(props) do this[key] = value end end this:on_display_resize() 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 * config.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 * config.menu_parent_opacity) end end, get_item_index_below_cursor = function(this) return math.ceil((cursor.y - this.ay + this.scroll_y) / this.scroll_step) end, get_first_visible_index = function(this) return round(this.scroll_y / this.scroll_step) + 1 end, get_last_visible_index = function(this) return round((this.scroll_y + this.height) / this.scroll_step) end, get_centermost_visible_index = function(this) return round((this.scroll_y + (this.height / 2)) / this.scroll_step) 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_item = (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_item = (index and index >= 1 and index <= #this.items) and index or nil 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:on_display_resize() if previous_active_value then this:activate_value(previous_active_value) end this:scroll_to_item(this.selected_item) 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) local default_anchor = this.scroll_height > this.scroll_step and this:get_centermost_visible_index() or this:get_last_visible_index() local current_index = this.selected_item or default_anchor + 1 this.selected_item = math.max(current_index - 1, 1) this:scroll_to_item(this.selected_item) end, next = function(this) local default_anchor = this.scroll_height > this.scroll_step and this:get_centermost_visible_index() or this:get_first_visible_index() local current_index = this.selected_item or default_anchor - 1 this.selected_item = math.min(current_index + 1, #this.items) this:scroll_to_item(this.selected_item) 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 transition_target:back() 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(config.menu_parent_opacity + ((1 - config.menu_parent_opacity) * pos)) end, function() menu.transition = nil elements:add('menu', target) update_proximities() end) end, open_selected_item = function(this) -- 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 target:open_selected_item() return end if this.selected_item then local item = this.items[this.selected_item] -- Is submenu if item.items then local opts = table_copy(opts) opts.parent_menu = this menu:open(item.items, this.open_item, opts) else menu:close(true) this.open_item(item.value) end end end, close = function(this) menu:close() end, on_global_mbtn_left_down = function(this) if this.proximity_raw == 0 then this.selected_item = 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_item = this:get_item_index_below_cursor() else if this.selected_item then this.selected_item = nil end end request_render() end, on_wheel_up = function(this) this.selected_item = 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_item = 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) this.selected_item = nil this:scroll_to(this.scroll_y - this.height) end, on_pgdwn = function(this) this.selected_item = nil this:scroll_to(this.scroll_y + this.height) end, on_home = function(this) this.selected_item = nil this:scroll_to(0) end, on_end = function(this) this.selected_item = nil this:scroll_to(this.scroll_height) 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-prev', self:create_action('prev'), 'repeatable') menu:add_key_binding('down', 'menu-next', self:create_action('next'), 'repeatable') menu:add_key_binding('left', 'menu-back', self:create_action('back')) menu:add_key_binding('right', 'menu-select', self:create_action('open_selected_item')) if options.menu_wasd_navigation then menu:add_key_binding('w', 'menu-prev-alt', self:create_action('prev'), 'repeatable') menu:add_key_binding('a', 'menu-back-alt', self:create_action('back')) menu:add_key_binding('s', 'menu-next-alt', self:create_action('next'), 'repeatable') menu:add_key_binding('d', 'menu-select-alt', self:create_action('open_selected_item')) end if options.menu_hjkl_navigation then menu:add_key_binding('h', 'menu-back-alt2', self:create_action('back')) menu:add_key_binding('j', 'menu-next-alt2', self:create_action('next'), 'repeatable') menu:add_key_binding('k', 'menu-prev-alt2', self:create_action('prev'), 'repeatable') menu:add_key_binding('l', 'menu-select-alt2', self:create_action('open_selected_item')) 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.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 o = mp.get_property_native('osd-dimensions') display.width = o.w display.height = o.h display.aspect = o.aspect -- Tell elements about this for _, element in elements:ipairs() do if element.on_display_resize ~= nil then element.on_display_resize(element) end end 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_mouse_buttons = false local capture_wheel = false local menu_only = menu:is_open() local mouse_left_elements = {} local mouse_entered_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_mouse_buttons = 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 if element.proximity_raw == 0 then -- Mouse is over element if element.captures and element.captures.mouse_buttons then capture_mouse_buttons = true end if element.captures and element.captures.wheel then capture_wheel = true end -- Mouse entered element area if previous_proximity_raw ~= 0 then mouse_entered_elements[#mouse_entered_elements + 1] = element end else -- Mouse left element area if previous_proximity_raw == 0 then mouse_left_elements[#mouse_left_elements + 1] = element end end end -- Enable key group captures elements request. if capture_mouse_buttons then forced_key_bindings.mouse_buttons:enable() else forced_key_bindings.mouse_buttons: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_left_elements) do element:trigger('mouse_leave') end for _, element in ipairs(mouse_entered_elements) do element:trigger('mouse_enter') end end -- ELEMENT RENDERERS function render_timeline(this) if this.size_max == 0 or state.duration == nil or state.position == 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.position / state.duration -- Background bar coordinates local bax = 0 local bay = display.height - size - this.bottom_border - this.top_border local bbx = display.width local bby = display.height -- Foreground bar coordinates local fax = bax local fay = bay + this.top_border local fbx = bbx * progress local fby = bby - this.bottom_border local foreground_size = bby - bay local foreground_coordinates = fax .. ',' .. fay .. ',' .. fbx .. ',' .. fby -- for clipping -- Background ass:new_event() 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:pos(0, 0) ass:draw_start() ass:rect_cw(bax, bay, bbx, bby) ass:draw_stop() -- Foreground 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() -- Seekable ranges if options.timeline_cached_ranges and state.cached_ranges then local range_height = math.max(foreground_size / 8, size_min) 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() ass:rect_cw(bbx * (range['start'] / state.duration), range_ay, bbx * (range['end'] / state.duration), range_ay + range_height) ass:draw_stop() end end -- Custom ranges if state.chapter_ranges ~= nil then for i, chapter_range in ipairs(state.chapter_ranges) do for i, range in ipairs(chapter_range.ranges) do local rax = display.width * (range['start'].time / state.duration) local rbx = display.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 -- Chapters if options.chapters ~= 'none' and state.chapters ~= nil and #state.chapters > 0 then local half_size = size / 2 local dots = false local chapter_size, chapter_y if options.chapters == 'dots' then dots = true chapter_size = math.min(6, (foreground_size / 2) + 2) chapter_y = math.min(fay + chapter_size, fay + half_size) elseif options.chapters == 'lines' then chapter_size = size chapter_y = fay + (chapter_size / 2) elseif options.chapters == 'lines-top' then chapter_size = math.min(this.size_max / 3.5, size) chapter_y = fay + (chapter_size / 2) elseif options.chapters == 'lines-bottom' then chapter_size = math.min(this.size_max / 3.5, size) chapter_y = fay + size - (chapter_size / 2) end if chapter_size ~= nil then -- for 1px chapter size, use the whole size of the bar including padding chapter_size = size <= 1 and foreground_size or chapter_size local chapter_half_size = chapter_size / 2 for i, chapter in ipairs(state.chapters) do local chapter_x = display.width * (chapter.time / state.duration) local color = chapter_x > fbx and options.color_foreground or options.color_background ass:new_event() ass:append('{\\blur0\\bord0\\1c&H' .. color .. '}') ass:append(ass_opacity(options.chapters_opacity)) ass:pos(0, 0) ass:draw_start() if dots then local bezier_stretch = chapter_size * 0.67 ass:move_to(chapter_x - chapter_half_size, chapter_y) ass:bezier_curve(chapter_x - chapter_half_size, chapter_y - bezier_stretch, chapter_x + chapter_half_size, chapter_y - bezier_stretch, chapter_x + chapter_half_size, chapter_y) ass:bezier_curve(chapter_x + chapter_half_size, chapter_y + bezier_stretch, chapter_x - chapter_half_size, chapter_y + bezier_stretch, chapter_x - chapter_half_size, chapter_y) else ass:rect_cw(chapter_x, chapter_y - chapter_half_size, chapter_x + 1, chapter_y + chapter_half_size) end ass:draw_stop() end end end if text_opacity > 0 then -- Elapsed time if state.elapsed_seconds then 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(spacing, fay + (size / 2)) ass:an(4) ass:append(state.elapsed_time) 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(spacing, fay + (size / 2)) ass:an(4) ass:append(state.elapsed_time) end -- Remaining time if state.remaining_seconds then 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(display.width - spacing, fay + (size / 2)) ass:an(6) ass:append('-' .. state.remaining_time) 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(display.width - spacing, fay + (size / 2)) ass:an(6) ass:append('-' .. state.remaining_time) end end if (this.proximity_raw == 0 or this.pressed) and not (elements.speed and elements.speed.dragging) then -- Hovered time local hovered_seconds = state.duration * (cursor.x / display.width) local box_half_width_guesstimate = (this.font_size * 4.2) / 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 .. bold_tag .. '') ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1))) ass:pos(math.min(math.max(cursor.x, box_half_width_guesstimate), display.width - box_half_width_guesstimate), fay) ass:an(2) ass:append(mp.format_time(hovered_seconds)) -- 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), (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), (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), (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), (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 = '0,0,' .. (this.title_bx - this.spacing) .. ',' .. this.size 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(0 + this.spacing, this.size / 2) ass:an(4) 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 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 timeline = elements.timeline local proximity = timeline:get_effective_proximity() local opacity = this.forced_proximity and this.forced_proximity or (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() - timeline.top_border - timeline.bottom_border 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.distance / this.step_distance) * options.speed_step) 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 or notch_speed > 100 then goto continue end 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() ::continue:: 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(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 = '' -- Clip items overflowing scroll area if item_ay <= this.ay or item_by >= this.by then item_clip = scroll_area_clip end if item_by < this.ay or item_ay > this.by then goto continue end local is_active = this.active_item == 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:len(), this.font_size) + this.item_content_spacing elseif has_submenu then hint_width = icon_size + this.item_content_spacing 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_item == 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 - 1) .. 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 ::continue:: 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 = config.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 if itable_find({'flash', 'static'}, options.pause_indicator) then elements:add('pause_indicator', Element.new( { base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8, paused = false, is_flash = options.pause_indicator == 'flash', is_static = options.pause_indicator == 'static', opacity = 0, init = function(this) local initial_call = true mp.observe_property('pause', 'bool', function(_, paused) if initial_call then initial_call = false return end this.paused = paused if options.pause_indicator == 'flash' then this.opacity = 1 this:tween_property('opacity', 1, 0, 0.15) else this.opacity = paused and 1 or 0 request_render() end end) end, render = function(this) if this.opacity == 0 then return end local ass = assdraw.ass_new() -- Background fadeout if this.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) * (this.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() elseif this.is_flash 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: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 })) end elements:add('timeline', Element.new({ captures = {mouse_buttons = true, wheel = true}, pressed = false, size_max = 0, size_min = 0, -- set in `on_display_resize` handler based on `state.fullscreen` size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command font_size = 0, -- calculated in on_display_resize top_border = options.timeline_border, bottom_border = 0, -- set dynamically in `border` property observer init = function(this) -- Toggle 1px bottom border for timeline in no-border mode mp.observe_property('border', 'bool', function(_, border) this.bottom_border = not border and options.timeline_border or 0 request_render() end) -- Flash on external changes if options.timeline_flash then mp.register_event('seek', function() local position = mp.get_property_native('playback-time') if position and state.position then local seek_length = math.abs(position - state.position) -- Don't flash on video looping (seek to 0) or tiny seeks (frame-step) if position > 0.5 and seek_length > 0.5 then this:flash() end end end) end end, get_effective_proximity = function(this) if (elements.volume_slider and elements.volume_slider.pressed) then return 0 end if this.pressed then return 1 end return this.forced_proximity and this.forced_proximity 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, on_display_resize = function(this) if state.fullscreen or state.maximized 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 = 0 this.ay = display.height - this.size_max - this.top_border - this.bottom_border this.bx = display.width this.by = display.height end, set_from_cursor = function(this) mp.commandv('seek', ((cursor.x / display.width) * 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) if options.timeline_step > 0 then mp.commandv('seek', -options.timeline_step) end end, on_wheel_down = function(this) if options.timeline_step > 0 then mp.commandv('seek', options.timeline_step) end end, render = render_timeline })) if options.top_bar_controls or options.top_bar_title then elements:add('top_bar', Element.new({ button_opacity = 0.8, enabled = false, init = function(this) mp.observe_property('border', 'bool', function(_, border) this.enabled = not border end) end, get_effective_proximity = function(this) if (elements.volume_slider and elements.volume_slider.pressed) or elements.curtain.opacity > 0 then return 0 end return this.forced_proximity and this.forced_proximity or this.proximity end, on_display_resize = function(this) this.size = (state.fullscreen or state.maximized) 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.title_bx = display.width - (options.top_bar_controls and (this.button_width * 3) or 0) this.ax = options.top_bar_title and 0 or this.title_bx this.ay = 0 this.bx = display.width this.by = this.size end, render = render_top_bar })) end if options.top_bar_controls then elements:add('window_controls_minimize', Element.new( { captures = {mouse_buttons = true}, on_display_resize = function(this) this.ax = display.width - (elements.top_bar.button_width * 3) this.ay = 0 this.bx = this.ax + elements.top_bar.button_width this.by = elements.top_bar.size end, on_mbtn_left_down = function() mp.commandv('cycle', 'window-minimized') end })) elements:add('window_controls_maximize', Element.new( { captures = {mouse_buttons = true}, on_display_resize = function(this) this.ax = display.width - (elements.top_bar.button_width * 2) this.ay = 0 this.bx = this.ax + elements.top_bar.button_width this.by = elements.top_bar.size end, on_mbtn_left_down = function() mp.commandv('cycle', 'window-maximized') end })) elements:add('window_controls_close', Element.new( { captures = {mouse_buttons = true}, on_display_resize = function(this) this.ax = display.width - elements.top_bar.button_width this.ay = 0 this.bx = this.ax + elements.top_bar.button_width this.by = elements.top_bar.size 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_resize` handler based on `state.fullscreen` height = nil, -- set in `on_display_resize` handler based on `state.fullscreen` margin = nil, -- set in `on_display_resize` handler based on `state.fullscreen` init = function(this) -- FLash on external changes if options.volume_flash then local is_initial_volume_call = true mp.observe_property('volume', 'number', function(_, value) if not is_initial_volume_call then this:flash() end is_initial_volume_call = false end) local is_initial_mute_call = true mp.observe_property('mute', 'bool', function(_, value) if not is_initial_mute_call then this:flash() end is_initial_mute_call = false end) end end, get_effective_proximity = function(this) if elements.volume_slider.pressed then return 1 end if elements.timeline.proximity_raw == 0 or elements.curtain.opacity > 0 then return 0 end return this.forced_proximity and this.forced_proximity or this.proximity end, on_display_resize = function(this) this.width = (state.fullscreen or state.maximized) 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 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, render = render_volume })) elements:add('volume_mute', Element.new( { captures = {mouse_buttons = true}, width = 0, height = 0, on_display_resize = 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( { captures = {mouse_buttons = true, wheel = true}, pressed = false, width = 0, height = 0, nudge_y = 0, -- vertical position where volume overflows 100 nudge_size = nil, -- set on resize font_size = nil, spacing = nil, on_display_resize = function(this) 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 options.speed then elements:add('speed', Element.new({ captures = {mouse_buttons = true, wheel = true}, dragging = nil, width = 0, height = 0, notches = 10, notch_every = 0.1, step_distance = nil, font_size = nil, init = function(this) -- Fade out/in on timeline mouse enter/leave elements.timeline:on('mouse_enter', function() if not this.dragging then this:fadeout() end end) elements.timeline:on('mouse_leave', function() if not this.dragging then this:fadein() end end) -- Flash on external changes if options.speed_flash then local initial_call = true mp.observe_property('speed', 'number', function() if not initial_call and not this.dragging then this:flash() end initial_call = false end) end end, fadeout = function(this) this:tween_property('forced_proximity', 1, 0, function(this) this.forced_proximity = 0 end) end, fadein = function(this) local get_current_proximity = function() return this.proximity end this:tween_property('forced_proximity', 0, get_current_proximity, function(this) this.forced_proximity = nil end) end, on_display_resize = function(this) this.height = (state.fullscreen or state.maximized) and options.speed_size_fullscreen or options.speed_size this.width = round(this.height * 3.6) this.notch_spacing = this.width / this.notches this.step_distance = this.notch_spacing * (options.speed_step / this.notch_every) this.ax = (display.width - this.width) / 2 this.by = display.height - elements.timeline.size_max 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_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, 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 local steps_dragged = round(-this.dragging.distance / this.step_distance) local new_speed = this.dragging.start_speed + (steps_dragged * options.speed_step) mp.set_property_native('speed', round(new_speed * 100) / 100) 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) if this.dragging and elements.timeline.proximity_raw == 0 then this:fadeout() end 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', state.speed - options.speed_step) end, on_wheel_down = function(this) mp.set_property_native('speed', state.speed + options.speed_step) 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 then local ass = assdraw.ass_new() ass:new_event() ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}') ass:append(ass_opacity(0.4, 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*)>([^>]+)') -- Invalid definition if start_patterns == nil then goto continue end 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 ::continue:: 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 -- Filter out chapters that were used as ranges state.chapters = itable_remove(chapters, function(chapter) return chapter._uosc_used_as_range_point == true end) 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 items = {} local items_by_command = {} local submenus_by_id = {} for line in io.lines(input_conf_path) do local key, command, title = string.match(line, ' *([%S]+) +(.*) #! *(.*)') if key then local is_dummy = key:sub(1, 1) == '#' local submenu_id = '' local target_menu = items 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 submenus_by_id[submenu_id] = {title = title_part, items = {}} target_menu[#target_menu + 1] = submenus_by_id[submenu_id] end target_menu = submenus_by_id[submenu_id].items else -- If command is already in menu, just append the key to it if items_by_command[command] then items_by_command[command].hint = items_by_command[command].hint .. ', ' .. key else items_by_command[command] = { title = title_part, hint = not is_dummy and key or nil, value = command } target_menu[#target_menu + 1] = items_by_command[command] end end end end end if #items > 0 then return items end end)() -- EVENT HANDLERS function create_state_setter(name) return function(_, value) state[name] = value dispatch_event_to_elements('prop_' .. name, value) request_render() end end function dispatch_event_to_elements(name, ...) for _, element in pairs(elements) do if element.proximity_raw == 0 then element:maybe('on_' .. name, ...) end element:maybe('on_global_' .. name, ...) end end function create_event_to_elements_dispatcher(name, ...) return function(...) dispatch_event_to_elements(name, ...) end 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() dispatch_event_to_elements('mouse_leave') end function handle_mouse_enter() cursor.hidden = false cursor.x, cursor.y = mp.get_mouse_pos() tween_element_stop(state) dispatch_event_to_elements('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 cursor.x, cursor.y = mp.get_mouse_pos() update_proximities() dispatch_event_to_elements('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 -- MENUS function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop) return function() if menu:is_open(track_type) then menu:close() return end local items = {} local active_item = nil for index, track in ipairs(mp.get_property_native('track-list')) do if track.type == track_type then if track.selected then active_item = track.id end items[#items + 1] = { title = (track.title and track.title or 'Track ' .. track.id), hint = track.lang and track.lang:upper() or nil, 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_item = active_item and active_item + 1 or 1 table.insert(items, 1, {hint = 'disabled', value = nil}) end menu:open(items, function(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 menu:close() end, {type = track_type, title = menu_title, active_item = active_item}) end end -- `menu_options`: -- **allowed_types** - table with file extensions to display -- **active_path** - full path of a file to preselect -- Rest of the options are passed to `menu:open()` function open_file_navigation_menu(directory, handle_select, menu_options) directory = serialize_path(directory) local directories, error = utils.readdir(directory.path, 'dirs') local files, error = get_files_in_directory(directory.path, menu_options.allowed_types) if not files or not directories then msg.error('Retrieving files from ' .. directory .. ' failed: ' .. (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 local items = not directory.dirname and {} or { {title = '..', hint = 'parent dir', value = directory.dirname} } for _, dir in ipairs(directories) do local serialized = serialize_path(utils.join_path(directory.path, dir)) items[#items + 1] = { title = serialized.basename, value = serialized.path, hint = '/' } end menu_options.active_item = nil for _, file in ipairs(files) do local serialized = serialize_path(utils.join_path(directory.path, file)) local item_index = #items + 1 items[item_index] = { title = serialized.basename, value = serialized.path } if menu_options.active_path == serialized.path then menu_options.active_item = item_index end end menu_options.title = directory.basename .. '/' menu:open(items, function(path) local meta, error = utils.file_info(path) if not meta then msg.error('Retrieving file info for ' .. path .. ' failed: ' .. (error or '')) return end if meta.is_dir then open_file_navigation_menu(path, handle_select, menu_options) else handle_select(path) menu:close() end end, menu_options) end -- VALUE SERIALIZATION/NORMALIZATION options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) options.chapters = itable_find({'dots', 'lines', 'lines-top', 'lines-bottom'}, options.chapters) and options.chapters or 'none' options.media_types = split(options.media_types, ' *, *') options.subtitle_types = split(options.subtitle_types, ' *, *') 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)() -- HOOKS mp.register_event('file-loaded', parse_chapters) mp.observe_property('chapter-list', 'native', parse_chapters) mp.observe_property('duration', 'number', create_state_setter('duration')) mp.observe_property('media-title', 'string', create_state_setter('media_title')) mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen')) mp.observe_property('window-maximized', 'bool', create_state_setter('maximized')) 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('playback-time', 'number', function(name, val) -- Ignore the initial call with nil value if val == nil then return end state.position = val state.elapsed_seconds = val state.elapsed_time = state.elapsed_seconds and mp.format_time(state.elapsed_seconds) or nil state.remaining_seconds = mp.get_property_native('playtime-remaining') state.remaining_time = state.remaining_seconds and mp.format_time(state.remaining_seconds) or nil request_render() end) mp.observe_property('osd-dimensions', 'native', function(name, val) update_display_dimensions() request_render() end) 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 end) -- 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() mp.set_key_bindings({ { 'mbtn_left', create_event_to_elements_dispatcher('mbtn_left_up'), create_event_to_elements_dispatcher('mbtn_left_down') }, {'mbtn_left_dbl', 'ignore'} }, 'mouse_buttons', 'force') mp.set_key_bindings({ {'wheel_up', create_event_to_elements_dispatcher('wheel_up')}, {'wheel_down', create_event_to_elements_dispatcher('wheel_down')} }, 'wheel', 'force') local groups = {} for _, group in ipairs({'mouse_buttons', '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, 'menu', function() if menu:is_open('menu') then menu:close() elseif state.context_menu_items then menu:open(state.context_menu_items, function(command) mp.command(command) end, {type = 'menu'}) 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') if path and not is_protocol(path) then open_file_navigation_menu(serialize_path(path).dirname, function(path) mp.commandv('sub-add', path) end, {type = 'load-subtitles', allowed_types = options.subtitle_types}) end 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', function() if menu:is_open('playlist') then menu:close() return end function serialize_playlist() local pos = mp.get_property_number('playlist-pos-1', 0) local items = {} local active_item for index, item in ipairs(mp.get_property_native('playlist')) do local is_url = item.filename:find('://') items[index] = { title = is_url and item.filename or serialize_path(item.filename).basename, hint = tostring(index), value = index } if index == pos then active_item = index end end return items, active_item end -- Update active index and playlist content on playlist changes function handle_playlist_change() if menu:is_open('playlist') then local items, active_item = serialize_playlist() elements.menu:set_items(items, { active_item = active_item, selected_item = active_item }) end end local items, active_item = serialize_playlist() menu:open(items, function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end, { type = 'playlist', title = 'Playlist', active_item = active_item, on_open = function() mp.observe_property('playlist', 'native', handle_playlist_change) mp.observe_property('playlist-pos-1', 'native', handle_playlist_change) end, on_close = function() mp.unobserve_property(handle_playlist_change) end }) end) mp.add_key_binding(nil, 'chapters', function() if menu:is_open('chapters') then menu:close() return end 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 -- Select first chapter from the end with time lower -- than current playing position (with 100ms leeway). function get_selected_chapter_index() local position = mp.get_property_native('playback-time') if not position then return nil end for index = #items, 1, -1 do if position - 0.1 > items[index].value then return index end end end -- Update selected chapter in chapter navigation menu function seek_handler() if menu:is_open('chapters') then elements.menu:activate_index(get_selected_chapter_index()) end end menu:open(items, function(time) mp.commandv('seek', tostring(time), 'absolute') end, { type = 'chapters', title = 'Chapters', active_item = get_selected_chapter_index(), on_open = function() mp.register_event('seek', seek_handler) end, on_close = function() mp.unregister_event(seek_handler) end }) 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, '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 path = serialize_path(mp.command_native({'expand-path', '~/'})) directory = path.path active_file = nil else local path = serialize_path(path) directory = path.dirname active_file = path.path 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, 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 path = mp.get_property_native('path') if not path or is_protocol(path) then return end path = normalize_path(path) local playlist_count = mp.get_property_native('playlist-count') if playlist_count > 1 then mp.commandv('playlist-remove', 'current') else local next_file = get_adjacent_file(path, 'forward', options.media_types) if menu:is_open('open-file') then elements.menu:delete_value(path) end if next_file then mp.commandv('loadfile', next_file) else mp.commandv('stop') end end os.remove(path) end) mp.add_key_binding(nil, 'delete-file-quit', function() local path = mp.get_property_native('path') if not path or is_protocol(path) then return end os.remove(normalize_path(path)) mp.command('quit') end) mp.add_key_binding(nil, 'open-config-directory', function() local config = serialize_path(mp.command_native( {'expand-path', '~~/mpv.conf'})) 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}) end)