diff options
Diffstat (limited to 'rt.lua')
| -rw-r--r-- | rt.lua | 968 |
1 files changed, 968 insertions, 0 deletions
@@ -0,0 +1,968 @@ +-- Copyright 2025 David Vazgenovich Shakaryan + +local config = require('config') +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 set_key_mapping(m) + ctx.binding_state.active = ctx.binding_state.mappings[m] +end + +function rt.load_data(force) + 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 _, 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'), + call_opts) + ctx.catalogue:load_xc_section(v) + 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 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 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 + +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 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 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) + end + options[i] = v + 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) + 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 + t.elements[#t.elements+1] = v + end + + -- contents of lazy-loaded groups should not be searchable + if v.type == 'group' and not v.lazy 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 = {}} + search_menu_options_build(options, 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: <text_with_cursor>' .. + ' <colour.info>(<num_matches>/<num_total>)' + + 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() + set_key_mapping('SEARCH') +end + +function rt.end_search() + local menu = state:menu() + menu.search_active = false + menu.title = 'Search results: <text>' .. + ' <colour.info>(<num_matches>/<num_total>)' + osd:dirty() + set_key_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() + set_key_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.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 + +return rt |
