diff options
| author | David Vazgenovich Shakaryan <dvshakaryan@gmail.com> | 2026-01-03 19:34:47 -0800 |
|---|---|---|
| committer | David Vazgenovich Shakaryan <dvshakaryan@gmail.com> | 2026-01-03 19:34:47 -0800 |
| commit | 63b29bfbafa87e18c583ac602bc4dd283590b2d1 (patch) | |
| tree | c875b4ea3f704db40b90dfe2fecb10dc97c24053 /osd.lua | |
| parent | d58a0a7abe32f668ee80efb9bb159d31863057fa (diff) | |
| download | mpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.gz mpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.xz | |
separate display logic from main code
Diffstat (limited to 'osd.lua')
| -rw-r--r-- | osd.lua | 322 |
1 files changed, 322 insertions, 0 deletions
@@ -0,0 +1,322 @@ +-- 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('<text>', asscape(menu.search_text)) + + if str:find('<text_with_cursor>', 1, true) then + str = str:gsub('<text_with_cursor>', + 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>', colours.info) + str = str:gsub('<num_matches>', #menu.options) + str = str:gsub('<num_total>', #menu.search_options) + 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 + 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 favourites[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 |
