From 82526665364389d92e0f2c33eba04678275863bb Mon Sep 17 00:00:00 2001 From: David Vazgenovich Shakaryan Date: Tue, 27 Jan 2026 01:12:16 -0800 Subject: on-demand calculation and update of option info Using metatables to calculate info strings on render, we can avoid precomputing it for all options when generating the menu, making certain menus open much faster. This also allows us to update dynamic info, e.g. the currently programme, while the menu is open. --- catalogue.lua | 87 +++++---------- epg.lua | 13 +++ osd.lua | 14 +-- rt.lua | 349 +++++++++++++++++++++++++++++++++++----------------------- state.lua | 14 ++- 5 files changed, 267 insertions(+), 210 deletions(-) diff --git a/catalogue.lua b/catalogue.lua index cf9d897..7f83e82 100644 --- a/catalogue.lua +++ b/catalogue.lua @@ -14,13 +14,6 @@ function catalogue.new() id = 'root', name = '/', }) - t:add({ - type = 'group', - id = 'favourites', - parent_id = 'root', - name = 'Favourites', - lazy = true, -- prevent infinite recursion on search - }) return t end @@ -50,7 +43,7 @@ end function mt:add(entry) self.data[entry.id] = entry - if entry.type == 'group' then + if entry.type == 'group' and not entry.children_f then entry.children = {} end @@ -76,57 +69,6 @@ function mt:add(entry) return entry end -function mt:load_xc_section(section) - self:add({ - section = section.id, - type = 'group', - group_type = 'category', - id = section.id .. ':category:0', - parent_id = 'root', - name = section.name, - }) - - -- currently, this will not correctly handle subcategories which come - -- before their parent category - for _, v in ipairs(section.categories) do - self:add({ - section = section.id, - type = 'group', - group_type = 'category', - id = section.id .. ':category:' .. v.category_id, - parent_id = section.id .. ':category:' .. v.parent_id, - name = util.strip(v.category_name), - }) - end - - for _, v in ipairs(section.elements) do - local vv = { - section = section.id, - parent_id = section.id .. ':category:' .. - v.category_id, - name = util.strip(v.name), - } - - if section.type == 'series' then - vv.type = 'group' - vv.group_type = 'series' - vv.id = section.id .. ':series:' .. v.series_id - vv.series_id = v.series_id - vv.img_url = util.strip_ne(v.cover) - vv.lazy = true -- avoid API calls on search - else - vv.type = 'stream' - vv.id = section.id .. ':stream:' .. v.stream_id - vv.stream_type = v.stream_type - vv.stream_id = v.stream_id - vv.img_url = util.strip_ne(v.stream_icon) - vv.epg_channel_id = util.strip_ne(v.epg_channel_id) - end - - self:add(vv) - end -end - function mt:path_to_root(entry) local path = {} @@ -153,4 +95,31 @@ function mt:path_from_root(entry) return path end +function mt:group_count(group) + if group.count then + return group.count + end + + if group.count_f then + return group.count_f(group) + end + + local count = 0 + -- only children, not children_f, is considered here. dynamically + -- loaded groups will have a count of 0 unless count or count_f is + -- specified. + for _, v in ipairs(group.children or {}) do + if v.type == 'group' then + count = count + (v.count or mt:group_count(v) or 0) + else + count = count + 1 + end + end + return count +end + +function mt:group_children(group) + return group.children_f and group.children_f(group) or group.children +end + return catalogue diff --git a/epg.lua b/epg.lua index c7af266..a3d84f2 100644 --- a/epg.lua +++ b/epg.lua @@ -66,4 +66,17 @@ function mt:scheduled_programme(ch, time) end end +function mt:next_programme(ch, time) + local progs = self.channels[ch] + if not progs then + return + end + + for _, v in ipairs(progs) do + if v.start > time then + return v + end + end +end + return epg diff --git a/osd.lua b/osd.lua index 58c6903..7075a73 100644 --- a/osd.lua +++ b/osd.lua @@ -235,8 +235,9 @@ function mt:option_text(opt, info) str = col .. '[' .. str .. ']' end - if opt.info and #opt.info > 0 then - str = str .. colour.info .. ' (' .. asscape(opt.info) .. ')' + local opt_info = opt.info + if opt_info and #opt_info > 0 then + str = str .. colour.info .. ' (' .. asscape(opt_info) .. ')' end return str @@ -542,6 +543,8 @@ function mt:render() end function mt:redraw(state) + self.redraw_time = os.time() + local out_titles = {} local out_options = {} @@ -574,15 +577,10 @@ function mt:redraw(state) #menu.options) do local opt = menu.options[i] - -- use real-time count for favourites - if opt.id == 'favourites' then - opt.info = tostring(#state.favourites) - end - local selected = i == menu.cursor and not menu.search_active local info = { selected = selected, - empty = (opt.type == 'group' and not opt.lazy and + empty = (opt.type == 'group' and opt.children and #opt.children == 0), playing = not not ( opt.id and opt.id == state.playing_id), diff --git a/rt.lua b/rt.lua index 3cc2aa8..67d18a5 100644 --- a/rt.lua +++ b/rt.lua @@ -29,7 +29,148 @@ local function cache_miss_status_msg(str) } end +local function series_children(series) + local info = ctx.xc:with_opts('get_series_info', series.series_id, + cache_miss_status_msg('Loading series info...')) + if not info or not info.seasons then + return {} + end + + local seasons = {} + for _, season in pairs(info.seasons) do + local episodes = {} + local season_num = tostring(season.season_number) + if info.episodes and info.episodes[season_num] then + for i, episode in pairs(info.episodes[season_num]) do + local epinfo = episode.info or {} + local t = { + name = util.strip(episode.title), + type = 'stream', + stream_type = 'series', + id = series.section .. ':stream:' .. + episode.id, + stream_id = episode.id, + img_url = util.strip_ne( + epinfo.movie_image), + } + t.info_data = { + name = t.name, + cover = t.img_url, + description = epinfo.plot, + releasedate = epinfo.releasedate, + duration = epinfo.duration, + video = epinfo.video, + audio = epinfo.audio, + } + episodes[#episodes+1] = t + end + end + + local count = tostring(#episodes) + local tmp = util.strip_ne(season.episode_count) + if tmp then + count = count .. '/' .. tmp + end + local t = { + type = 'group', + group_type = 'season', + id = series.id .. ':season:' .. season.id, + children = episodes, + name = util.strip(season.name), + info = count, + img_url = util.strip_ne(season.cover_big) or + util.strip_ne(season.cover), + } + t.info_data = { + name = t.name, + cover = t.img_url, + description = season.overview, + releasedate = season.air_date, + num_episodes = count, + } + seasons[#seasons+1] = t + end + + return seasons +end + +local function catalogue_add_section(sect, cats, elems) + ctx.catalogue:add({ + section = sect.id, + type = 'group', + group_type = 'category', + id = sect.id .. ':category:0', + parent_id = 'root', + name = sect.name, + }) + + -- currently, this will not correctly handle subcategories which come + -- before their parent category + for _, v in ipairs(cats) do + ctx.catalogue:add({ + section = sect.id, + type = 'group', + group_type = 'category', + id = sect.id .. ':category:' .. v.category_id, + parent_id = sect.id .. ':category:' .. v.parent_id, + name = util.strip(v.category_name), + }) + end + + for _, v in ipairs(elems) do + local vv = { + section = sect.id, + parent_id = sect.id .. ':category:' .. v.category_id, + name = util.strip(v.name), + } + + if sect.type == 'series' then + vv.type = 'group' + vv.group_type = 'series' + vv.id = sect.id .. ':series:' .. v.series_id + vv.series_id = v.series_id + vv.img_url = util.strip_ne(v.cover) + vv.count = 1 + vv.hide_count = true + vv.children_f = series_children + else + vv.type = 'stream' + vv.id = sect.id .. ':stream:' .. v.stream_id + vv.stream_type = v.stream_type + vv.stream_id = v.stream_id + vv.img_url = util.strip_ne(v.stream_icon) + vv.epg_channel_id = util.strip_ne(v.epg_channel_id) + end + + ctx.catalogue:add(vv) + end +end + function rt.load_data(force) + ctx.catalogue:add({ + type = 'group', + id = 'favourites', + parent_id = 'root', + name = 'Favourites', + count_f = function() + return #state.favourites + end, + children_f = function() + local options = {} + for i, id in ipairs(state.favourites) do + -- missing favourites are displayed so that + -- they can be removed + options[i] = ctx.catalogue:get(id) or + { + id = id, + name = id, + missing = true, + } + end + return options + end, + }) + local arr = { {id = 'live', name = 'Live TV', type = 'live'}, {id = 'movie', name = 'Movies', type = 'vod'}, @@ -56,15 +197,15 @@ function rt.load_data(force) end end, } - for _, v in ipairs(arr) do - sect_str = base_str .. ' » ' .. v.name - v.categories = ctx.xc:with_opts( - 'get_' .. v.type .. '_categories', call_opts) - v.elements = ctx.xc:with_opts( - v.type == 'series' and 'get_series' or - ('get_' .. v.type .. '_streams'), + for _, sect in ipairs(arr) do + sect_str = base_str .. ' » ' .. sect.name + local cats = ctx.xc:with_opts( + 'get_' .. sect.type .. '_categories', call_opts) + local elems = ctx.xc:with_opts( + sect.type == 'series' and 'get_series' or + ('get_' .. sect.type .. '_streams'), call_opts) - ctx.catalogue:load_xc_section(v) + catalogue_add_section(sect, cats, elems) end osd:set_status('Loading EPG...') @@ -257,140 +398,65 @@ local function add_programme(opt, time) end end -local function group_count(group) - if group.children and not group.lazy then - local count = 0 - for _, v in ipairs(group.children) do - if v.type == 'stream' or v.group_type == 'series' then - count = count + 1 - elseif v.type == 'group' then - local c = group_count(v) - if c then - count = count + c - end - end - end - return count - elseif group.id == 'favourites' then - -- not recursive - return #state.favourites - end -end +local menu_option_mt_count = 0 +local menu_option_mt = { + __index = function(t, k) + local v = t._v -local function favourites_group_menu_options(group) - local options = {} - local time = os.time() - for _, id in ipairs(state.favourites) do - local obj = ctx.catalogue:get(id) - if obj then - obj = util.copy_table(obj) - add_programme(obj, time) - local c = group_count(obj) - if c then - obj.info = tostring(c) - end - local path = ctx.catalogue:path_from_root(obj) - if path then - obj.path = path - end - options[#options+1] = obj - else - -- display missing favourites so that they can be - -- removed - options[#options+1] = { - id = id, - name = id, - missing = true, - } - end - end - return options -end + if k == 'info' then + if v.type == 'group' and not v.hide_count then + local c = ctx.catalogue:group_count(v) + local ret = c and tostring(c) or '' + rawset(t, 'info', ret) + return ret + elseif v.epg_channel_id then + if t._info_exp and + osd.redraw_time < + t._info_exp then + return t._info + end -local function series_group_menu_options(series) - local info = ctx.xc:with_opts('get_series_info', series.series_id, - cache_miss_status_msg('Loading series info...')) - if not info or not info.seasons then - return {} - end + local prog = ctx.epg:scheduled_programme( + v.epg_channel_id, + osd.redraw_time) + local ret = prog and prog.title or '' + local exp = prog and prog.stop + + if not prog then + prog = ctx.epg:next_programme( + v.epg_channel_id, + osd.redraw_time) + exp = prog and prog.start + + if not prog then + rawset(t, 'info', ret) + return ret + end + end - local seasons = {} - for _, season in pairs(info.seasons) do - local episodes = {} - local season_num = tostring(season.season_number) - if info.episodes and info.episodes[season_num] then - for i, episode in pairs(info.episodes[season_num]) do - local epinfo = episode.info or {} - local t = { - name = util.strip(episode.title), - type = 'stream', - stream_type = 'series', - id = series.section .. ':stream:' .. - episode.id, - stream_id = episode.id, - img_url = util.strip_ne( - epinfo.movie_image), - } - t.info_data = { - name = t.name, - cover = t.img_url, - description = epinfo.plot, - releasedate = epinfo.releasedate, - duration = epinfo.duration, - video = epinfo.video, - audio = epinfo.audio, - } - episodes[#episodes+1] = t + rawset(t, '_info_exp', exp) + rawset(t, '_info', ret) + return ret end end - local count = tostring(#episodes) - local tmp = util.strip_ne(season.episode_count) - if tmp then - count = count .. '/' .. tmp - end - local t = { - type = 'group', - group_type = 'season', - id = series.id .. ':season:' .. season.id, - children = episodes, - name = util.strip(season.name), - info = count, - img_url = util.strip_ne(season.cover_big) or - util.strip_ne(season.cover), - } - t.info_data = { - name = t.name, - cover = t.img_url, - description = season.overview, - releasedate = season.air_date, - num_episodes = count, - } - seasons[#seasons+1] = t - end - - return seasons -end + return v[k] + end, +} local function group_menu_options(group) - if group.id == 'favourites' then - return favourites_group_menu_options(group) - end - - if group.group_type == 'series' then - return series_group_menu_options(group) - end - local options = {} - local time = os.time() - for i, v in ipairs(group.children) do - v = util.copy_table(v) - add_programme(v, time) - local c = group_count(v) - if c then - v.info = tostring(c) + for i, v in ipairs(ctx.catalogue:group_children(group)) do + local t = setmetatable({_v = v}, menu_option_mt) + + if group.id == 'favourites' then + local path = ctx.catalogue:path_from_root(v) + if path then + t.path = path + end end - options[i] = v + + options[i] = t end return options end @@ -784,12 +850,9 @@ function rt.open_option_info(opt) end local function search_menu_options_build(options, t, path) - local menu = state:menu() - local path = path or {} - for _, v in ipairs(options) do - local v = util.copy_table(v) v.path = path + if v.type == 'group' and v.group_type ~= 'series' then t.categories[#t.categories+1] = v else @@ -797,7 +860,7 @@ local function search_menu_options_build(options, t, path) end -- contents of lazy-loaded groups should not be searchable - if v.type == 'group' and not v.lazy then + if v.type == 'group' and v.children then local path = util.copy_table(path) path[#path+1] = v search_menu_options_build( @@ -808,7 +871,17 @@ end local function search_menu_options(options) local t = {categories = {}, elements = {}} - search_menu_options_build(options, t) + + local opts = {} + for i, v in ipairs(options) do + -- menu options may contain dynamic data that is updated on + -- redraw. using a proxy table instead of copying allows a + -- single update to target both the source and search menus. + opts[i] = setmetatable({}, {__index = v}) + end + + -- empty table is needed to shadow existing path from proxied table + search_menu_options_build(opts, t, {}) -- display categories first local ret = t.categories diff --git a/state.lua b/state.lua index da26a9a..a88fe41 100644 --- a/state.lua +++ b/state.lua @@ -167,7 +167,7 @@ function menu_mt:update_search_matches() local options = {} for _, v in ipairs(self.search_options) do - local matches = {} + local matches local name = v.name if not case_sensitive then @@ -180,13 +180,17 @@ function menu_mt:update_search_matches() if not i then break end + matches = matches or {} matches[#matches+1] = {start = i, stop = j} end - if #matches > 0 then - local t = util.copy_table(v) - t.matches = matches - options[#options+1] = t + if matches then + -- search options may contain dynamic data that is + -- updated on redraw. using a proxy table instead of + -- copying prevents potential updates on every change + -- of search text. + options[#options+1] = setmetatable( + {matches = matches}, {__index = v}) end end -- cgit v1.2.3-70-g09d2