summaryrefslogtreecommitdiff
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
parentd58a0a7abe32f668ee80efb9bb159d31863057fa (diff)
downloadmpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.gz
mpv-iptv-menu-63b29bfbafa87e18c583ac602bc4dd283590b2d1.tar.xz
separate display logic from main code
-rw-r--r--config.lua31
-rw-r--r--main.lua324
-rw-r--r--osd.lua322
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
diff --git a/main.lua b/main.lua
index 5d08ba9..e0f1f48 100644
--- a/main.lua
+++ b/main.lua
@@ -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)
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