-- Copyright 2025 David Vazgenovich Shakaryan local config = require('config') local input = require('input') local rx = require('rx') 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) return rx.series_children( series, ctx.xc:with_opts('get_series_info', series.series_id, cache_miss_status_msg('Loading series info...'))) 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_id(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 function rt.push_group_menu(group) state:push_menu({ options = rx.menu_options_group(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. 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 menu.options = util.stable_kmerge( menu.options, rx.menu_options_group(ctx.catalogue:get(menu.group_id)), 'id') if sorted then menu:set_sort(true, rx.sort_menu_options) end if opt then cursor_to_id(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_id(opt.path[i].id) rt.push_group_menu(opt.path[i]) end cursor_to_id(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_id(path[i].id) rt.push_group_menu(path[i]) end cursor_to_id(id) osd:dirty() end local function open_option_programme_info(opt, img_url) local prog = opt.programme local options = rx.menu_options_programme_info(prog) if not options then return end state:push_menu({ options = options, title = 'Programme Info: ' .. prog.title, type = 'info', img_url = img_url, }) osd:dirty() end local function open_option_channel_epg(opt) local ch = opt.epg_channel_id:lower() local options, idx = rx.menu_options_channel_epg(ch) if not options then return end state:push_menu({ options = options, title = 'EPG: ' .. opt.name .. ' (' .. ch .. ')', type = 'epg', img_url = opt.img_url, }) rt.set_cursor(idx, {centre = true}) osd:dirty() end local function open_option_entry_info(title, info) local options, img = rx.menu_options_entry_info(info) if not options then return end state:push_menu({ options = options, title = title, type = 'info', img_url = img, }) 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_entry_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_entry_info('Series Info: ' .. opt.name, info) end local function open_option_season_info(opt) open_option_entry_info( 'Season Info: ' .. opt.name, {info = opt.info_data}) end local function open_option_episode_info(opt) open_option_entry_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 opt.programme then open_option_programme_info(opt, menu.img_url) elseif opt.epg_channel_id then open_option_channel_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 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 = rx.menu_options_search(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, rx.sort_menu_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