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 | |
| parent | d58a0a7abe32f668ee80efb9bb159d31863057fa (diff) | |
| download | mpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.gz mpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.xz | |
separate display logic from main code
| -rw-r--r-- | config.lua | 31 | ||||
| -rw-r--r-- | main.lua | 324 | ||||
| -rw-r--r-- | osd.lua | 322 |
3 files changed, 373 insertions, 304 deletions
diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..1186b6b --- /dev/null +++ b/config.lua @@ -0,0 +1,31 @@ +-- Copyright 2025 David Vazgenovich Shakaryan + +local mp_utils = require('mp.utils') + +local config = {} + +-- font size is in units of osd height, which is scaled to 720 +config.font_size = 20 + +config.bg_alpha = '44' +config.colours = { + title = '999999', + option = 'ffffff', + info = '666666', + group = 'ffdd99', + group_empty = '776644', + selected = '00ff00', + selected_empty = '337733', + search_hl = 'ddff00', + search_hl_empty = '778800', + search_path = '666666', + search_path_empty = '444444', + icon_playing = '3366ff', + icon_favourite = 'ff00ff', + icon_active = 'ff9900', +} + +config.cache_dir = mp_utils.join_path(mp.get_script_directory(), 'cache') +config.img_dir = mp_utils.join_path(mp.get_script_directory(), 'img') + +return config @@ -1,34 +1,16 @@ -- Copyright 2025 David Vazgenovich Shakaryan local cacher = require('cacher') +local config = require('config') local util = require('util') local _catalogue = require('catalogue') local _downloader = require('downloader') local _epg = require('epg') +local _osd = require('osd') local _xc = require('xc') local mp_utils = require('mp.utils') --- font size is in units of osd height, which is scaled to 720 -local font_size = 20 -local colours = { - bg = '{\\alpha&H44&\\c&H&}', - title = '{\\c&999999&}', - option = '{\\c}', - info = '{\\c&666666&}', - group = '{\\c&H99DDFF&}', - group_empty = '{\\c&H446677&}', - selected = '{\\c&HFF00&}', - selected_empty = '{\\c&H337733&}', - search_hl = '{\\c&FFDD&}', - search_hl_empty = '{\\c&8877&}', - search_path = '{\\c&666666&}', - search_path_empty = '{\\c&444444&}', - icon_playing = '{\\c&HFF6633&}', - icon_favourite = '{\\c&HFF00FF&}', - icon_active = '{\\c&H99FF&}', -} - local script_name = mp.get_script_name() local script_dir = mp.get_script_directory() @@ -39,7 +21,7 @@ local xc = _xc.new({ pass = mp.get_opt('iptv_menu.xc_pass'), }) xc = cacher.wrap(xc, { - directory = mp_utils.join_path(script_dir, 'cache'), + directory = config.cache_dir, prefix = (xc.server:gsub('%W', '_')), time = 24*60*60, functions = { @@ -55,21 +37,7 @@ xc = cacher.wrap(xc, { }, }) -local osd = mp.create_osd_overlay('ass-events') -local osd_width = 0 -local osd_height = 0 -local osd_scale = 1 -local osd_lines = math.floor((720 / font_size) + 0.5) - 1 -local osd_padding = math.floor((720 - (osd_lines * font_size)) / 2) -local osd_cursor_glyph = '{\\p1\\pbo' .. math.floor(font_size / 5) .. '}' .. - 'm 0 0 l ' .. math.ceil(font_size / 32) .. ' 0 ' .. - math.ceil(font_size / 32) .. ' ' .. font_size .. - ' 0 ' .. font_size .. '{\\p0}' -local osd_bg = mp.create_osd_overlay('ass-events') -osd_bg.z = -1 -osd_bg.data = '{\\pos(0,0)}' .. colours.bg .. - '{\\p1}m 0 0 l 7680 0 7680 720 0 720{\\p0}' -local osd_img +local osd = _osd.new() local catalogue = _catalogue.new() local epg = _epg.new() @@ -80,10 +48,18 @@ local depth = 0 local menus = {} local key_bindings = {} -local update_osd +local function update_osd() + osd:redraw(menus, depth, favourites, playing_id) +end + +local function osd_menu_lines() + return osd:menu_lines(depth) +end -local function get_image_path(url, dl) - local path = 'img/' .. url:gsub('%W', '_') +-- FIXME leaving this here as a global for now since the image is downloaded +-- during osd redraw but this function has other dependencies +function get_image_path(url, dl) + local path = mp_utils.join_path(config.img_dir, url:gsub('%W', '_')) local f = mp_utils.file_info(path) if f then @@ -92,7 +68,7 @@ local function get_image_path(url, dl) if dl then downloader:schedule(url, path, function(_, file) - if osd_img and file == osd_img.path then + if osd.img and file == osd.img.path then update_osd() end end) @@ -126,250 +102,6 @@ local function load_data() end end -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 - -local function osd_menu_lines() - if depth > 1 then - -- leaves an extra line for padding between titles and options - return osd_lines - depth - else - return osd_lines - end -end - -local function osd_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)) .. - osd_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 - -local function osd_option_icons(opt, info) - local str = '' - if info.selected then - str = colours.selected .. '› ' - end - if opt.id and opt.id == playing_id then - str = str .. colours.icon_playing .. '\226\143\186 ' - end - if opt.id and favourites[opt.id] then - str = str .. colours.icon_favourite .. '★ ' - end - if opt.active then - str = str .. colours.icon_active .. '\226\143\186 ' - end - return str -end - -local function osd_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 - -local function osd_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 - -local function update_osd_image(path, menu_res) - -- images require *real* dimensions and coordinates, unlike other OSD - -- functions which scale the given values automatically - local padding = osd_scale * osd_padding - local start = osd_scale * menu_res.x1 - local fs = osd_scale * 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(osd_width - start - 2*padding, - (osd_height - 2*padding) * ratio))) - local h = math.floor(w / ratio) - local x = math.floor(osd_width - padding - w) - local y = math.floor(padding) - - local disp - if osd_img and osd_img.loaded and path == osd_img.path and - w == osd_img.cmd.w and h == osd_img.cmd.h then - if x == osd_img.cmd.x and y == osd_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 - - osd_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 - osd_img.loaded = true - if not osd.hidden then - mp.command_native(osd_img.cmd) - end - else - mp.command_native( - {name = 'overlay-remove', id = osd_img.cmd.id}) - end -end - -local function remove_osd_image() - mp.command_native({name = 'overlay-remove', id = osd_img.cmd.id}) - osd_img = nil -end - -function update_osd() - local out = {} - - if depth > 1 then - for i = 2, depth do - out[#out+1] = osd_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 + osd_menu_lines() - 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), - } - out[#out+1] = osd_option_icons(opt, info) .. - osd_option_text(opt, info) .. - osd_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 - osd.data = '{\\q2\\fs' .. font_size .. '\\pos(' .. osd_padding .. - ',' .. osd_padding .. ')}' .. table.concat(out, '\\N') - - osd.compute_bounds = not not img - local res = osd:update() - if img then - update_osd_image(img, res) - elseif osd_img then - remove_osd_image() - end - osd_bg:update() -end - local function set_cursor(pos, opts) local menu = menus[depth] local lines = osd_menu_lines() @@ -496,7 +228,7 @@ local function add_programme(opt, time) if opt.epg_channel_id then local prog = epg:scheduled_programme(opt.epg_channel_id, time) if prog then - opt.info = asscape(prog.title) + opt.info = prog.title end end end @@ -1202,7 +934,7 @@ end -- might eventually change this to a selective override, since some of these -- builtin keys could still be useful while the menus are open. local function set_key_bindings() - if osd.hidden then + if osd:is_hidden() then unbind_keys() mp.command_native({'enable-section', 'default'}) elseif menus[depth].search_active then @@ -1215,28 +947,12 @@ local function set_key_bindings() end local function toggle_menu() - osd.hidden = not osd.hidden - osd:update() - osd_bg.hidden = osd.hidden - osd_bg:update() - - if osd_img then - if osd.hidden then - mp.command_native( - {name = 'overlay-remove', id = osd_img.cmd.id}) - else - mp.command_native(osd_img.cmd) - end - end - + osd:toggle_hidden() set_key_bindings() end mp.observe_property('osd-dimensions', 'native', function(_, val) - osd_width = val.w - osd_height = val.h - osd_scale = osd_height / 720 - + osd:resize(val.w, val.h) update_osd() end) @@ -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 |
