-- Copyright 2025 David Vazgenovich Shakaryan local config = require('config') local input = require('input') local util = require('util') local rt = {} local state local osd local ctx function rt.init(_state, _osd, _ctx) state = _state osd = _osd ctx = _ctx end local function cache_miss_status_msg(str) -- doesn't redraw after clearing message return { before_miss = function() osd:set_status(str) osd:redraw(state) end, after_miss = function() osd:set_status() end, } 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'}, {id = 'series', name = 'Series', type = 'series'}, } local base_str = 'Loading catalogue' local sect_str local disp_str local call_opts = { force = not not force, before_hit = function() if disp_str ~= base_str and disp_str ~= sect_str then osd:set_status(base_str .. '...') osd:redraw(state) disp_str = base_str end end, before_miss = function() if disp_str ~= sect_str then osd:set_status(sect_str .. '...') osd:redraw(state) disp_str = sect_str end end, } 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) catalogue_add_section(sect, cats, elems) end osd:set_status('Loading EPG...') osd:redraw(state) ctx.epg:load_xc_data( ctx.xc:with_opts('get_epg', {force = not not force})) osd:set_status() local t = util.read_json_file(config.favourites_file) state.favourites = t.favourites or {} end local function save_favourites() util.write_json_file( config.favourites_file, {favourites = state.favourites}) end function rt.set_cursor(pos, opts) local moved = state:menu():set_cursor(pos, osd:menu_lines(state), opts) if moved then osd:dirty() end end function rt.cursor_up() rt.set_cursor(state:menu().cursor - 1, {margin = config.scroll_margin}) end function rt.cursor_down() rt.set_cursor(state:menu().cursor + 1, {margin = config.scroll_margin}) end function rt.cursor_start() rt.set_cursor(1) end function rt.cursor_end() rt.set_cursor(#state:menu().options) end function rt.cursor_page_up() rt.set_cursor( state:menu().cursor - osd:menu_lines(state), {keep_offset = true, margin = config.scroll_margin}) end function rt.cursor_page_down() rt.set_cursor( state:menu().cursor + osd:menu_lines(state), {keep_offset = true, margin = config.scroll_margin}) end function rt.cursor_wheel_up() rt.set_cursor(state:menu().cursor - 1, {keep_offset = true}) end function rt.cursor_wheel_down() rt.set_cursor(state:menu().cursor + 1, {keep_offset = true}) end function rt.cursor_wheel_page_up() rt.set_cursor( state:menu().cursor - osd:menu_lines(state), {keep_offset = true}) end function rt.cursor_wheel_page_down() rt.set_cursor( state:menu().cursor + osd:menu_lines(state), {keep_offset = true}) end local function cursor_to_object(id) for i, v in ipairs(state:menu().options) do if v.id == id then rt.set_cursor(i, {centre = true}) return end end end local function move_option(pos, opts) local menu = state:menu() if menu.group_id ~= 'favourites' or menu.sorted then return end local prev_cursor = menu.cursor local opts = opts or {} rt.set_cursor(pos, opts) if menu.cursor == prev_cursor then return end local opt = table.remove(menu.options, prev_cursor) table.insert(menu.options, menu.cursor, opt) local ind = state:favourited(opt.id) if ind then state:remove_favourite_at(ind) state:insert_favourite_before_next_in_menu(opt.id) save_favourites() end osd:dirty() end function rt.move_option_up() move_option(state:menu().cursor - 1, {margin = config.scroll_margin}) end function rt.move_option_down() move_option(state:menu().cursor + 1, {margin = config.scroll_margin}) end function rt.move_option_start() move_option(1) end function rt.move_option_end() move_option(#state:menu().options) end function rt.move_option_page_up() move_option( state:menu().cursor - osd:menu_lines(state), {keep_offset = true, margin = config.scroll_margin}) end function rt.move_option_page_down() move_option( state:menu().cursor + osd:menu_lines(state), {keep_offset = true, margin = config.scroll_margin}) end function rt.move_option_wheel_up() move_option(state:menu().cursor - 1, {keep_offset = true}) end function rt.move_option_wheel_down() move_option(state:menu().cursor + 1, {keep_offset = true}) end function rt.move_option_wheel_page_up() move_option( state:menu().cursor - osd:menu_lines(state), {keep_offset = true}) end function rt.move_option_wheel_page_down() move_option( state:menu().cursor + osd:menu_lines(state), {keep_offset = true}) end local function sort_options(options) local scores = {} for _, v in ipairs(options) 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(options, 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 local function add_programme(opt, time) if opt.epg_channel_id then local prog = ctx.epg:scheduled_programme( opt.epg_channel_id, time) if prog then opt.info = prog.title end end end local menu_option_mt_count = 0 local menu_option_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 '' 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 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 rawset(t, '_info_exp', exp) rawset(t, '_info', ret) return ret end end return v[k] end, } local function group_menu_options(group) local options = {} 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] = t end return options end function rt.push_group_menu(group) state:push_menu({ options = group_menu_options(group), title = group.name, type = 'group', group_id = group.id, }) end -- refresh options when navigating up the stack to a previous favourites menu. -- existing menu options are never removed, even if unfavourited. the new order -- is always respected, while still preserving the relative order of existing -- options when possible. local function refresh_favourites_menu() local menu = state:menu() local opt = menu.options[menu.cursor] local sorted = menu.sorted if sorted then menu:set_sort(false) end local options = group_menu_options(ctx.catalogue:get(menu.group_id)) local pos = {} for i, v in ipairs(options) do pos[v.id] = i end local res = {} local seen = {} local function append(v) if not seen[v.id] then res[#res+1] = v seen[v.id] = true end end local ind = 1 for _, v in ipairs(menu.options) do if pos[v.id] then while ind <= pos[v.id] do append(options[ind]) ind = ind + 1 end end append(v) end for i = ind, #options do append(options[i]) end menu.options = res if sorted then menu:set_sort(true, sort_options) end if opt then cursor_to_object(opt.id) end end function rt.prev_menu() state.depth = state.depth - 1 if state.depth == 0 then -- reset main menu rt.push_group_menu(ctx.catalogue:get(state.menus[1].group_id)) else if state:menu().group_id == 'favourites' then refresh_favourites_menu() end end osd:dirty() end local function play_stream(stream) local url = stream.stream_url or ctx.xc:stream_url(stream.stream_type, stream.stream_id) if not url then return end -- add a per-file option containing the stream id, allowing it to be -- retrieved when a start-file event is received mp.commandv('loadfile', url, 'replace', -1, 'script-opt=iptv_menu.playing_id=' .. stream.id) end function rt.select_option() local menu = state:menu() local opt = menu.options[menu.cursor] if not opt or not opt.id then return end if opt.type == 'group' then rt.push_group_menu(opt) osd:dirty() elseif opt.type == 'stream' then play_stream(opt) end end function rt.favourite_option() local menu = state:menu() local opt = menu.options[menu.cursor] if not opt or not opt.id then return end local ind = state:favourited(opt.id) if ind then state:remove_favourite_at(ind) elseif menu.group_id == 'favourites' then state:insert_favourite_before_next_in_menu(opt.id) else state:add_favourite(opt.id) end save_favourites() osd:dirty() end function rt.goto_option() local menu = state:menu() local opt = menu.options[menu.cursor] if not opt or not opt.path then return end if menu.group_id == 'favourites' then state.depth = 1 elseif menu.type == 'search' then state.depth = state.depth - 1 if state:menu().group_id == 'favourites' then refresh_favourites_menu() end end for i = 1, #opt.path do cursor_to_object(opt.path[i].id) rt.push_group_menu(opt.path[i]) end cursor_to_object(opt.id) osd:dirty() end function rt.goto_playing() local id = state.playing_id local obj = id and ctx.catalogue:get(id) local path = obj and ctx.catalogue:path_to_root(obj) if not path then return end state.depth = 1 for i = #path, 1, -1 do cursor_to_object(path[i].id) rt.push_group_menu(path[i]) end cursor_to_object(id) osd:dirty() end local function open_epg_programme(prog, img_url) 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 local menu = state:push_menu({ options = options, title = 'Programme: ' .. prog.title, type = 'epg', }) if img_url then menu.img_url = img_url end osd:dirty() end local function open_option_epg(opt) local ch = opt.epg_channel_id:lower() local progs = ctx.epg:channel_programmes(ch) if not progs then return end local options = {} local curr = 0 local time = os.time() for i, v in ipairs(progs) do prog = { name = os.date('%a %d %b %H:%M', v.start) .. ' ' .. os.date('%H:%M', v.stop) .. ' ' .. v.title, info = v.desc, programme = v, } if curr == 0 and time >= v.start and time < v.stop then curr = i prog.active = true end options[i] = prog end local menu = state:push_menu({ options = options, title = 'EPG: ' .. opt.name .. ' (' .. ch .. ')', type = 'epg', }) if opt.img_url then menu.img_url = opt.img_url end rt.set_cursor(curr, {centre = true}) osd:dirty() 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 local function open_option_title_info(title, 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 m = { options = options, title = title, type = 'info', } local img = util.strip_ne(info.cover_big) or util.strip_ne(info.cover) if img then m.img_url = img end state:push_menu(m) osd:dirty() end local function open_option_movie_info(opt) local info = ctx.xc:with_opts('get_vod_info', opt.stream_id, cache_miss_status_msg('Loading movie info...')) open_option_title_info('Movie Info: ' .. opt.name, info) end local function open_option_series_info(opt) local info = ctx.xc:with_opts('get_series_info', opt.series_id, cache_miss_status_msg('Loading series info...')) open_option_title_info('Series Info: ' .. opt.name, info) end local function open_option_season_info(opt) open_option_title_info( 'Season Info: ' .. opt.name, {info = opt.info_data}) end local function open_option_episode_info(opt) open_option_title_info( 'Episode Info: ' .. opt.name, {info = opt.info_data}) end function rt.open_option_info(opt) local menu = state:menu() local opt = opt or menu.options[menu.cursor] if not opt then return end if menu.type == 'epg' and opt.programme then open_epg_programme(opt.programme, menu.img_url) elseif opt.epg_channel_id then open_option_epg(opt) elseif opt.group_type == 'series' then open_option_series_info(opt) elseif opt.group_type == 'season' then open_option_season_info(opt) elseif opt.stream_type == 'series' then open_option_episode_info(opt) elseif opt.stream_type == 'movie' then open_option_movie_info(opt) end end local function search_menu_options_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 search_menu_options_build( group_menu_options(v), t, path) end end end local function search_menu_options(options) local t = {categories = {}, elements = {}} 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 for _, v in ipairs(t.elements) do ret[#ret+1] = v end return ret end function rt.search_input_char(ev) if ev.event ~= 'down' and ev.event ~= 'repeat' then return end local menu = state:menu() menu:set_search_text( menu.search_text:sub(1, menu.search_cursor - 1) .. ev.key_text .. menu.search_text:sub(menu.search_cursor)) menu:set_search_cursor(menu.search_cursor + #ev.key_text) osd:dirty() end function rt.search_input_bs() local menu = state:menu() if menu.search_cursor <= 1 then return end local pos = util.utf8_seek(menu.search_text, menu.search_cursor, -1) menu:set_search_text( menu.search_text:sub(1, pos - 1) .. menu.search_text:sub(menu.search_cursor)) menu:set_search_cursor(pos) osd:dirty() end function rt.search_input_del() local menu = state:menu() if menu.search_cursor > #menu.search_text then return end menu:set_search_text( menu.search_text:sub(1, menu.search_cursor - 1) .. menu.search_text:sub(util.utf8_seek( menu.search_text, menu.search_cursor, 1))) osd:dirty() end local function set_search_cursor(pos) if state:menu():set_search_cursor(pos) then osd:dirty() end end function rt.search_cursor_left() local menu = state:menu() set_search_cursor(util.utf8_seek( menu.search_text, menu.search_cursor, -1)) end function rt.search_cursor_right() local menu = state:menu() set_search_cursor(util.utf8_seek( menu.search_text, menu.search_cursor, 1)) end function rt.search_cursor_start() set_search_cursor(1) end function rt.search_cursor_end() set_search_cursor(#state:menu().search_text + 1) end function rt.start_search() local menu = state:menu() local title = 'Searching: ' .. ' (/)' if menu.type == 'search' then -- resuming search, save previous state menu.prev_search_text = menu.search_text menu.prev_cursor = menu.cursor menu.prev_view_top = menu.view_top menu.title = title menu.search_active = true menu:set_search_cursor(#menu.search_text + 1) menu:set_cursor(1) else state:push_menu({ title = title, type = 'search', options = search_menu_options(menu.options), search_active = true, }) end osd:dirty() input.set_mapping('SEARCH') end function rt.end_search() local menu = state:menu() menu.search_active = false menu.title = 'Search results: ' .. ' (/)' osd:dirty() input.set_mapping('MENU') end function rt.cancel_search() local menu = state:menu() -- cancelling resumed search restores previous state if menu.prev_search_text then menu:set_search_text(menu.prev_search_text) menu.cursor = menu.prev_cursor menu.view_top = menu.prev_view_top rt.end_search() return end menu.search_active = false state.depth = state.depth - 1 osd:dirty() input.set_mapping('MENU') end function rt.toggle_menu_sort() local menu = state:menu() if menu.type ~= 'group' and menu.type ~= 'search' then return end menu:set_sort(not menu.sorted, sort_options) osd:dirty() end function rt.click_menu() local line = osd.mstate.option_line if line < 1 then return end local menu = state:menu() local pos = menu.view_top + line - 1 if pos > #menu.options then return end rt.set_cursor(pos) end function rt.dbl_click_menu() local line = osd.mstate.option_line if line == 0 then return end -- title if line < 0 then if line == -1 then rt.set_cursor(1) else state.depth = state.depth + line + 1 osd:dirty() end return end local menu = state:menu() local pos = menu.view_top + line - 1 if pos ~= menu.cursor then return end rt.select_option() end function rt.dbl_click_scrollbar() -- set_cursor handles out-of-bounds moves (when ratio == 1) rt.set_cursor( math.floor(osd.mstate.ratio * #state:menu().options) + 1, {centre = true}) end function rt.dbl_right_click_menu() local line = osd.mstate.option_line if line < 1 then return end local menu = state:menu() local pos = menu.view_top + line - 1 if pos > #menu.options then return end rt.open_option_info(menu.options[pos]) end function rt.reload_data() if state.depth > 1 then osd:flash_error('Can only reload data from root menu') return end ctx.catalogue = _catalogue.new() rt.load_data(true) state.depth = 0 rt.push_group_menu(ctx.catalogue:get('root')) end function rt.set_osc_visibility(osd_hidden) local v = osd_hidden and state.saved_osc_visibility or 'never' mp.command_native({'script-message', 'osc-visibility', v, ''}) end function rt.toggle_menu() local hidden = osd:toggle_hidden() if state.saved_osc_visibility ~= 'never' then rt.set_osc_visibility(hidden) end input.activate(not hidden) end return rt