-- Copyright 2025 David Vazgenovich Shakaryan local config = require('config') local mp_utils = require('mp.utils') local osd = {} local mt = {} mt.__index = mt local colours = {} for k, v in pairs(config.colours) do -- constructed backwards as ASS colour tags expect BGR but we configure -- them as RGB tag = '&}' for byte in v:gmatch('..') do tag = byte:upper() .. tag end colours[k] = '{\\c&H' .. tag end local function draw_rect(x1, y1, x2, y2) return string.format( '{\\p1}m %f %f l %f %f %f %f %f %f{\\p0}', x1, y1, x2, y1, x2, y2, x1, y2) end local cursor_glyph = '{\\pbo' .. math.floor(config.font_size / 5) .. '}' .. draw_rect(0, 0, math.ceil(config.font_size / 32), config.font_size) local function asscape(str) -- remove newlines before escaping, since we don't want output to -- unexpectedly span multiple lines return mp.command_native({'escape-ass', (str:gsub('\n', ' '))}) end function osd.new(init) local lines = math.floor((720 / config.font_size) + 0.5) - 1 local t = setmetatable({ fg = mp.create_osd_overlay('ass-events'), bg = mp.create_osd_overlay('ass-events'), width = 0, height = 0, scale = 1, lines = lines, padding = math.floor((720 - (lines * config.font_size)) / 2), }, mt) t.bg.z = -1 for k, v in pairs(init or {}) do t[k] = v end return t end function mt:resize(w, h) self.width = w self.height = h self.scale = h / 720 end function mt:menu_lines(state) if state.depth > 1 then -- leaves an extra line for padding between titles and options return self.lines - state.depth else return self.lines end end function mt:menu_title(menu) local str = asscape(menu.title) local col = menu.search_active and colours.selected or colours.title if menu.type == 'search' then str = str:gsub('', asscape(menu.search_text)) if str:find('', 1, true) then str = str:gsub('', asscape(menu.search_text:sub( 1, menu.search_cursor - 1)) .. cursor_glyph .. asscape(menu.search_text:sub( menu.search_cursor))) end str = str:gsub('', colours.info) str = str:gsub('', #menu.options) str = str:gsub('', #menu.search_options) end if menu.sorted then str = colours.icon_sorted .. '⇅ ' .. col .. str end return col .. ' » ' .. str end function mt:option_icons(opt, info) local str = '' if info.selected then str = colours.selected .. '› ' end if info.playing then str = str .. colours.icon_playing .. '\226\143\186 ' end if info.favourited then str = str .. colours.icon_favourite .. '★ ' end if opt.active then str = str .. colours.icon_active .. '\226\143\186 ' end if opt.missing then str = str .. colours.icon_missing .. '!!!MISSING!!! ' end return str end function mt:option_text(opt, info) local str = opt.name local col = colours.option if info.selected then col = info.empty and colours.selected_empty or colours.selected elseif info.empty then col = colours.group_empty elseif opt.type == 'group' then col = colours.group end if opt.matches then local buf = '' local hl_col = info.empty and colours.search_hl_empty or colours.search_hl local n = 0 for _, match in ipairs(opt.matches) do buf = buf .. col .. asscape(str:sub(n + 1, match.start - 1)) .. hl_col .. asscape(str:sub(match.start, match.stop)) n = match.stop end str = buf .. col .. asscape(str:sub(n + 1)) else str = col .. asscape(str) end if opt.type == 'group' then str = col .. '[' .. str .. ']' end if opt.info and #opt.info > 0 then str = str .. colours.info .. ' (' .. asscape(opt.info) .. ')' end return str end function mt:option_path(opt, info) if not opt.path or #opt.path == 0 then return '' end local str = info.empty and colours.search_path_empty or colours.search_path for i = #opt.path, 1, -1 do str = str .. ' « ' .. asscape(opt.path[i].name) end return str end function mt:load_img() local f = mp_utils.file_info(self.img.path) if not f then return end if self.magick_cmd_id then mp.abort_async_command(self.magick_cmd_id) end local magick_cmd = { 'magick', self.img.path, '-depth', '8', '-resize', string.format( 'x%%[fx:max(%d, min(%d, %d*h/w))]', self.img.min_h, self.img.max_h, self.img.max_w), '-print', '%w %h', 'tmp.bgra' } print('exec: ' .. mp_utils.to_string(magick_cmd)) self.img.magick_cmd = magick_cmd self.magick_cmd = magick_cmd self.magick_cmd_id = mp.command_native_async({ name = 'subprocess', args = magick_cmd, capture_stdout = true, playback_only = false, }, function (_, res) if magick_cmd ~= self.magick_cmd then return end self.magick_cmd = nil self.magick_cmd_id = nil if not self.img or not res or res.status ~= 0 then return end local w, h = res.stdout:match('(.+) (.+)') self.img.cmd = { name = 'overlay-add', id = 0, file = 'tmp.bgra', w = w, h = h, x = self.img.right - w, y = self.img.top, fmt = 'bgra', offset = 0, stride = 4*w, } self:draw_img() end) end function mt:clear_img(purge) if self.img_overlay_id then mp.command_native( {name = 'overlay-remove', id = self.img_overlay_id}) self.img_overlay_id = nil end if purge then self.img = nil end end function mt:draw_img() if not self.img then return end if not self.img.magick_cmd then self:load_img() return end if self.img.cmd and not self:is_hidden() then mp.command_native(self.img.cmd) self.img_overlay_id = self.img.cmd.id end end -- returns (upd, new) -- upd: if anything changed, e.g. position -- new: if the image also changed, i.e. different path or size function mt:set_img(path, menu_res) -- images require *real* dimensions and coordinates, unlike other OSD -- functions which scale the given values automatically local padding = self.scale * self.padding local start = self.scale * menu_res.x1 local fs = self.scale * config.font_size -- images are scaled to fit the available OSD area when treated as -- having the 2:3 aspect ratio of a standard film poster. when this -- results in an image height less than a minimum font-size-derived -- value, the image is instead scaled to this minimum height. this -- ensures that a given screen has all images, regardless of original -- aspect ratio, scaled to either a common width or a common height. -- -- because we have only the total width of the current OSD text, we -- cannot slightly reduce the image width to avoid overlapping some -- longer line at the bottom, as we do not know where the offending -- line is. if it were possible to place images *under* text, maybe -- we'd allow overlap, but mpv currently hard-codes this order. local min_h = math.floor(3 * fs) local max_h = math.floor(math.max(min_h, math.min( self.height - 2*padding, (self.width - start - 2*padding) * 3/2))) local max_w = math.floor(max_h * 2/3) local top = math.floor(padding) local right = math.floor(self.width - padding) if self.img and path == self.img.path and min_h == self.img.min_h and max_h == self.img.max_h and max_w == self.img.max_w then if top == self.img.top and right == self.img.right then return false, false end self.img.top = top self.img.right = right if self.img.cmd then self.img.cmd.x = right - self.img.cmd.w self.img.cmd.y = top end return true, false end self.img = { path = path, min_h = min_h, max_h = max_h, max_w = max_w, top = top, right = right, } return true, true end function mt:draw_scrollbar(state) local menu = state:menu() local opts = #menu.options local lines = self:menu_lines(state) if opts <= lines then return end local top = self.padding + ((self.lines - lines) * config.font_size) local h = 720 - self.padding - top local w = self.padding - 4 local hh = math.max(config.font_size / 2, h * lines / opts) local pos = (h - hh) * (menu.view_top - 1) / (opts - lines) return ( -- bg colours.scrollbar_bg .. '{\\pos(2,' .. top .. ')}' .. draw_rect(0, 0, w, h) .. '\n' .. -- fg colours.scrollbar_fg .. '{\\bord0}' .. '{\\pos(2,' .. top + pos .. ')}' .. draw_rect(0, 0, w, hh)) end function mt:redraw(state) local out = {} if state.depth > 1 then for i = 2, state.depth do out[#out+1] = self:menu_title(state.menus[i]) end out[#out+1] = ' ' -- space character for correct line height end local menu = state:menu() local img if menu.img_url then img = menu.img_url end for i = menu.view_top, math.min( menu.view_top + self:menu_lines(state) - 1, #menu.options) do local opt = menu.options[i] -- use real-time count for favourites if opt.id == 'favourites' then opt.info = tostring(#state.favourites) end local selected = i == menu.cursor and not menu.search_active local info = { selected = selected, empty = (opt.type == 'group' and not opt.lazy and #opt.children == 0), playing = not not ( opt.id and opt.id == state.playing_id), favourited = not not ( opt.id and state:favourited(opt.id)), } out[#out+1] = self:option_icons(opt, info) .. self:option_text(opt, info) .. self:option_path(opt, info) if selected and opt.img_url then img = opt.img_url end end -- \q2 disables line wrapping self.fg.data = '{\\q2\\fs' .. config.font_size .. '\\pos(' .. self.padding .. ',' .. self.padding .. ')}' .. table.concat(out, '\\N') self.bg.data = '{\\pos(0,0)\\alpha&H' .. config.bg_alpha .. '&\\c&H&}' .. draw_rect(0, 0, 7680, 720) local sb = self:draw_scrollbar(state) if sb then self.bg.data = self.bg.data .. '\n' .. sb end self.fg.compute_bounds = not not img local res = self.fg:update() self.fg.compute_bounds = false self.bg:update() if img and self.img_path_func then local path = self.img_path_func(img, function(path) if self.img and path == self.img.path then self:draw_img() end end) local upd, new = self:set_img(path, res) if upd then if new then self:clear_img() end self:draw_img() end else self:clear_img(true) end end function mt:toggle_hidden() local hidden = not self:is_hidden() self.fg.hidden = hidden self.fg:update() self.bg.hidden = hidden self.bg:update() if hidden then self:clear_img() else self:draw_img() end end function mt:is_hidden() return self.fg.hidden end return osd