-- Copyright 2025 David Vazgenovich Shakaryan local util = require('util') local rx = {} local state local osd local ctx function rx.init(_state, _osd, _ctx) state = _state osd = _osd ctx = _ctx end function rx.series_children(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 entry_mt = { __index = function(t, k) local v = t._v 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 '' if not v.count_f then rawset(t, 'info', ret) end return ret elseif v.epg_channel_id then local time = osd.redraw_time if t._exp and time < t._exp then return t._info end local prog = ctx.epg:scheduled_programme( v.epg_channel_id, time) local ret = prog and prog.title or '' local exp = prog and prog.stop rawset(t, 'active_programme', prog) if not prog then prog = ctx.epg:next_programme( v.epg_channel_id, time) exp = prog and prog.start end rawset(t, exp and '_info' or 'info', ret) rawset(t, '_exp', exp) return ret end end return v[k] end, } function rx.menu_options_group(group) local options = {} for i, v in ipairs(ctx.catalogue:group_children(group)) do local t = setmetatable({_v = v}, entry_mt) if group.id == 'favourites' then local path = ctx.catalogue:path_from_root(v) if path then t.path = path end end options[i] = t end return options end local function add_info_field(dst, k, v, fmt) local str = util.strip_ne(v) if not str then return end if fmt then str = string.format(fmt, str) end if k then str = k .. ': ' .. str end -- continuation lines are 4 chars shorter and indented with 2 em spaces for i, v in ipairs(util.wrap(str, 80, 76)) do if i > 1 then v = '\xe2\x80\x83\xe2\x80\x83' .. v end dst[#dst+1] = {name = v} end end local function add_info_set(options, set) if #set > 0 then options[#options+1] = {name = ''} for _, v in ipairs(set) do options[#options+1] = v end end end function rx.menu_options_entry_info(info) if not info or not info.info then return end local info = info.info local options = {} add_info_field(options, nil, info.name) add_info_field(options, 'Directed by', info.director) add_info_field(options, 'Starring', info.cast) local desc = util.strip_ne(info.description) or util.strip_ne(info.plot) if desc then options[#options+1] = {name = ''} for _, v in ipairs(util.wrap(desc, 80)) do options[#options+1] = {name = v} end end local set = {} add_info_field(set, 'Genre', info.genre) local date = util.strip_ne(info.releasedate) or util.strip_ne(info.releaseDate) if date then local y, m, d = date:match('(%d+)-(%d+)-(%d+)') if y then local dt = {year = y, month = m, day = d} add_info_field(set, 'Release date', os.date('%d %B %Y', os.time(dt))) end end add_info_field(set, 'Episode count', info.num_episodes) add_info_field(set, 'Running time', info.duration) add_info_set(options, set) local set = {} if info.video then local w = util.strip_ne(info.video.width) local h = util.strip_ne(info.video.height) if w and h then local res = w .. 'x' .. h local ar = util.strip_ne( info.video.display_aspect_ratio) if ar then res = res .. ' (' .. ar .. ')' end add_info_field(set, 'Resolution', res) end add_info_field(set, 'Video codec', info.video.codec_long_name) end if info.audio then add_info_field(set, 'Audio codec', info.audio.codec_long_name) end if info.bitrate ~= 0 then add_info_field(set, 'Bitrate', info.bitrate, '%s Kbps') end add_info_set(options, set) local ytid = util.strip_ne(info.youtube_trailer) if ytid then local url = 'https://youtu.be/' .. ytid add_info_set(options, {{ name = 'Trailer: ' .. url, type = 'stream', id = 'youtube:' .. ytid, stream_url = url, }}) end local img = util.strip_ne(info.cover_big) or util.strip_ne(info.cover) return options, img end local programme_mt = { __index = function(t, k) if k == 'active' then local time = osd.redraw_time if t._exp and time < t._exp then return t._active end local v = t.programme local ret = time >= v.start and time < v.stop local exp = time < v.start and v.start or ret and v.stop or nil rawset(t, exp and '_active' or 'active', ret) rawset(t, '_exp', exp) return ret end end, } function rx.menu_options_channel_epg(ch) local progs = ctx.epg:channel_programmes(ch) if not progs then return end local options = {} local idx local time = os.time() for i, v in ipairs(progs) do local prog = { name = os.date('%a %d %b %H:%M', v.start) .. ' ' .. os.date('%H:%M', v.stop) .. ' ' .. v.title, type = 'programme', info = v.desc, programme = v, } if not idx and time < v.stop then idx = i end options[i] = setmetatable(prog, programme_mt) end return options, idx or #options end function rx.menu_options_programme_info(prog) local options = { {name = 'Title: ' .. prog.title}, {name = 'Start: ' .. os.date('%a %d %b %H:%M', prog.start)}, {name = 'Stop: ' .. os.date('%a %d %b %H:%M', prog.stop)}, } if prog.desc then options[#options+1] = {name = ''} for _, v in ipairs(util.wrap(prog.desc, 80)) do options[#options+1] = {name = v} end end return options end local function menu_options_search_build(options, t, path) for _, v in ipairs(options) do v.path = path if v.type == 'group' and v.group_type ~= 'series' then t.categories[#t.categories+1] = v else t.elements[#t.elements+1] = v end -- contents of lazy-loaded groups should not be searchable if v.type == 'group' and v.children then local path = util.copy_table(path) path[#path+1] = v menu_options_search_build( rx.menu_options_group(v), t, path) end end end function rx.menu_options_search(opts) local t = {categories = {}, elements = {}} local options = {} for i, v in ipairs(opts) 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. options[i] = setmetatable({}, {__index = v}) end -- empty table is needed to shadow existing path from proxied table menu_options_search_build(options, t, {}) -- display categories first options = t.categories for _, v in ipairs(t.elements) do options[#options+1] = v end return options end function rx.sort_menu_options(opts) local scores = {} for _, v in ipairs(opts) do local score = 0 if v.missing then score = score - 4 end if state:favourited(v.id) then score = score + 2 end if v.type == 'group' and v.group_type ~= 'series' then score = score + 1 end scores[v] = score end table.sort(opts, function(a, b) local sa = scores[a] local sb = scores[b] if sa ~= sb then return sa > sb else return a.name < b.name end end) end return rx