-- 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 cursor_glyph = '{\\p1\\pbo' .. math.floor(config.font_size / 5) .. '}' .. 'm 0 0 l ' .. math.ceil(config.font_size / 32) .. ' 0 ' .. math.ceil(config.font_size / 32) .. ' ' .. config.font_size .. ' 0 ' .. config.font_size .. '{\\p0}' 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() local lines = math.floor((720 / config.font_size) + 0.5) - 1 local t = { 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), img = nil, } t.bg.z = -1 t.bg.data = '{\\pos(0,0)\\alpha&H' .. config.bg_alpha .. '&\\c&H&}' .. '{\\p1}m 0 0 l 7680 0 7680 720 0 720{\\p0}' return setmetatable(t, mt) end function mt:resize(w, h) self.width = w self.height = h self.scale = h / 720 end function mt:menu_lines(depth) if depth > 1 then -- leaves an extra line for padding between titles and options return self.lines - 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:update_image(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 -- width is generally computed to 2/3 (standard aspect ratio of movie -- posters) of OSD height. when longer OSD text extends into this area, -- the remaining space is used, with a minimum font-size-derived value -- when there is not enough (or any) space left, which may overlap the -- tail end of text. -- -- for consistency, any source images with a different aspect ratio are -- resized, confining them to the area of a "standard" poster. -- -- 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 ratio = 2/3 local w = math.floor(math.max(3 * fs * ratio, math.min(self.width - start - 2*padding, (self.height - 2*padding) * ratio))) local h = math.floor(w / ratio) local x = math.floor(self.width - padding - w) local y = math.floor(padding) local disp if self.img and self.img.loaded and path == self.img.path and w == self.img.cmd.w and h == self.img.cmd.h then if x == self.img.cmd.x and y == self.img.cmd.y then return end disp = true else local f = mp_utils.file_info(path) if f then local cmd = 'magick \'' .. path .. '\'' .. ' -background none' .. ' -gravity northwest' .. ' -depth 8' .. ' -resize ' .. w .. 'x' .. h .. ' -extent ' .. w .. 'x' .. h .. ' tmp.bgra' print('exec: ' .. cmd) os.execute(cmd) disp = true end end self.img = { path = path, cmd = { name = 'overlay-add', id = 0, file = 'tmp.bgra', w = w, h = h, x = x, y = y, fmt = 'bgra', offset = 0, stride = 4*w, }, } if disp then self.img.loaded = true if not self.fg.hidden then mp.command_native(self.img.cmd) end else mp.command_native( {name = 'overlay-remove', id = self.img.cmd.id}) end end function mt:remove_image() mp.command_native({name = 'overlay-remove', id = self.img.cmd.id}) self.img = nil end function mt:redraw(menus, depth, favourites, playing_id) local out = {} if depth > 1 then for i = 2, depth do out[#out+1] = self:menu_title(menus[i]) end out[#out+1] = ' ' -- space character for correct line height end local menu = menus[depth] local img if menu.img_url then img = get_image_path(menu.img_url, true) end for i = menu.view_top, math.min( menu.view_top + self:menu_lines(depth) - 1, #menu.options) do local opt = menu.options[i] 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 == playing_id), favourited = not not (opt.id and 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.image and opt.image ~= '' then img = get_image_path(opt.image, true) end end -- \q2 disables line wrapping self.fg.data = '{\\q2\\fs' .. config.font_size .. '\\pos(' .. self.padding .. ',' .. self.padding .. ')}' .. table.concat(out, '\\N') self.fg.compute_bounds = not not img local res = self.fg:update() if img then self:update_image(img, res) elseif self.img then self:remove_image() end self.bg:update() end function mt:toggle_hidden() self.fg.hidden = not self.fg.hidden self.fg:update() self.bg.hidden = self.fg.hidden self.bg:update() if self.img then if self.fg.hidden then mp.command_native({ name = 'overlay-remove', id = self.img.cmd.id}) else mp.command_native(self.img.cmd) end end end function mt:is_hidden() return self.fg.hidden end return osd