summaryrefslogtreecommitdiff
path: root/osd.lua
diff options
context:
space:
mode:
authorDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-03 19:34:47 -0800
committerDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-03 19:34:47 -0800
commit63b29bfbafa87e18c583ac602bc4dd283590b2d1 (patch)
treec875b4ea3f704db40b90dfe2fecb10dc97c24053 /osd.lua
parentd58a0a7abe32f668ee80efb9bb159d31863057fa (diff)
downloadmpv-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.lua322
1 files changed, 322 insertions, 0 deletions
diff --git a/osd.lua b/osd.lua
new file mode 100644
index 0000000..e7bb9eb
--- /dev/null
+++ b/osd.lua
@@ -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