From af9a5c2c065be5fecf9fbbea6db58ad746e3a582 Mon Sep 17 00:00:00 2001 From: David Vazgenovich Shakaryan Date: Fri, 23 Jan 2026 00:15:34 -0800 Subject: move routines to separate module --- main.lua | 1092 +++++--------------------------------------------------------- rt.lua | 968 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1045 insertions(+), 1015 deletions(-) create mode 100644 rt.lua diff --git a/main.lua b/main.lua index 31c58ab..ea11ebf 100644 --- a/main.lua +++ b/main.lua @@ -2,6 +2,7 @@ local cacher = require('cacher') local config = require('config') +local rt = require('rt') local util = require('util') local _catalogue = require('catalogue') local _downloader = require('downloader') @@ -45,6 +46,13 @@ xc = cacher.wrap(xc, { local catalogue = _catalogue.new() local epg = _epg.new() +local ctx = { + binding_state = binding_state, + catalogue = catalogue, + epg = epg, + xc = xc, +} + local osd local function dl_img(url, path, cb) downloader:schedule(url, path, function(success, _, path) @@ -72,941 +80,7 @@ osd = _osd.new({ 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) - binding_state.active = binding_state.mappings[m] -end - -local function 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 = xc:with_opts( - 'get_' .. v.type .. '_categories', call_opts) - v.elements = xc:with_opts( - v.type == 'series' and 'get_series' or - ('get_' .. v.type .. '_streams'), - call_opts) - catalogue:load_xc_section(v) - end - - osd:set_status('Loading EPG...') - osd:redraw(state) - epg:load_xc_data(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 - -local function set_cursor(pos, opts) - local moved = state:menu():set_cursor(pos, osd:menu_lines(state), opts) - if moved then - osd:dirty() - end -end - -local function cursor_up() - set_cursor(state:menu().cursor - 1, {margin = config.scroll_margin}) -end - -local function cursor_down() - set_cursor(state:menu().cursor + 1, {margin = config.scroll_margin}) -end - -local function cursor_start() - set_cursor(1) -end - -local function cursor_end() - set_cursor(#state:menu().options) -end - -local function cursor_page_up() - set_cursor( - state:menu().cursor - osd:menu_lines(state), - {keep_offset = true, margin = config.scroll_margin}) -end - -local function cursor_page_down() - set_cursor( - state:menu().cursor + osd:menu_lines(state), - {keep_offset = true, margin = config.scroll_margin}) -end - -local function cursor_wheel_up() - set_cursor(state:menu().cursor - 1, {keep_offset = true}) -end - -local function cursor_wheel_down() - set_cursor(state:menu().cursor + 1, {keep_offset = true}) -end - -local function cursor_wheel_page_up() - set_cursor( - state:menu().cursor - osd:menu_lines(state), - {keep_offset = true}) -end - -local function cursor_wheel_page_down() - 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 - 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 {} - 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 - -local function move_option_up() - move_option(state:menu().cursor - 1, {margin = config.scroll_margin}) -end - -local function move_option_down() - move_option(state:menu().cursor + 1, {margin = config.scroll_margin}) -end - -local function move_option_start() - move_option(1) -end - -local function move_option_end() - move_option(#state:menu().options) -end - -local function move_option_page_up() - move_option( - state:menu().cursor - osd:menu_lines(state), - {keep_offset = true, margin = config.scroll_margin}) -end - -local function move_option_page_down() - move_option( - state:menu().cursor + osd:menu_lines(state), - {keep_offset = true, margin = config.scroll_margin}) -end - -local function move_option_wheel_up() - move_option(state:menu().cursor - 1, {keep_offset = true}) -end - -local function move_option_wheel_down() - move_option(state:menu().cursor + 1, {keep_offset = true}) -end - -local function move_option_wheel_page_up() - move_option( - state:menu().cursor - osd:menu_lines(state), - {keep_offset = true}) -end - -local function 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 = 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 = 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 = 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 = 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 - -local function 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(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 - -local function prev_menu() - state.depth = state.depth - 1 - - if state.depth == 0 then - -- reset main menu - push_group_menu(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 - 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 - -local function 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 - push_group_menu(opt) - osd:dirty() - elseif opt.type == 'stream' then - play_stream(opt) - end -end - -local function 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 - -local function 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) - push_group_menu(opt.path[i]) - end - cursor_to_object(opt.id) - - osd:dirty() -end - -local function goto_playing() - local id = state.playing_id - local obj = id and catalogue:get(id) - local path = obj and 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) - 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 = 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 - 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 = 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 = 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 - -local function 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 - -local function 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 - -local function 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 - -local function 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 - -local function search_cursor_left() - local menu = state:menu() - set_search_cursor(util.utf8_seek( - menu.search_text, menu.search_cursor, -1)) -end - -local function search_cursor_right() - local menu = state:menu() - set_search_cursor(util.utf8_seek( - menu.search_text, menu.search_cursor, 1)) -end - -local function search_cursor_start() - set_search_cursor(1) -end - -local function search_cursor_end() - set_search_cursor(#state:menu().search_text + 1) -end - -local function 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() - set_key_mapping('SEARCH') -end - -local function end_search() - local menu = state:menu() - menu.search_active = false - menu.title = 'Search results: ' .. - ' (/)' - osd:dirty() - set_key_mapping('MENU') -end - -local function 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 - end_search() - return - end - - menu.search_active = false - state.depth = state.depth - 1 - osd:dirty() - set_key_mapping('MENU') -end - -local function 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 +rt.init(state, osd, ctx) local function mouse_has_drifted(x1, y1, x2, y2) return math.abs(x1 - x2) > config.click_max_drift or @@ -1091,7 +165,7 @@ local function mouse_click_left_menu(dbl, line) end if line == -1 then - set_cursor(1) + rt.set_cursor(1) else state.depth = state.depth + line + 1 osd:dirty() @@ -1106,13 +180,13 @@ local function mouse_click_left_menu(dbl, line) return end - select_option() + rt.select_option() else if pos > #menu.options then return end - set_cursor(pos) + rt.set_cursor(pos) end end @@ -1122,7 +196,7 @@ local function mouse_click_left_scrollbar(dbl, ratio) end -- set_cursor handles out-of-bounds moves (when ratio == 1) - set_cursor( + rt.set_cursor( math.floor(ratio * #state:menu().options) + 1, {centre = true}) end @@ -1151,7 +225,7 @@ local function mouse_click_right_menu(dbl, line) return end - open_option_info(menu.options[pos]) + rt.open_option_info(menu.options[pos]) end local function mouse_click_right(ev) @@ -1165,82 +239,70 @@ local function mouse_click_right(ev) end end -local function reload_data() - if state.depth > 1 then - osd:flash_error('Can only reload data from root menu') - return - end - - catalogue = _catalogue.new() - load_data(true) - state.depth = 0 - push_group_menu(catalogue:get('root')) -end - binding_state.mappings.MENU = { - ['BS'] = {prev_menu}, - ['/'] = {start_search}, - ['Ctrl+s'] = {toggle_menu_sort}, - ['Ctrl+R'] = {reload_data}, - - ['ENTER'] = {select_option}, - ['Ctrl+f'] = {favourite_option}, - ['g'] = {goto_option}, - ['i'] = {open_option_info}, - ['?'] = {open_option_info}, - ['Ctrl+p'] = {goto_playing}, - - ['MBTN_LEFT'] = {mouse_click_left, 'complex'}, - ['MBTN_RIGHT'] = {mouse_click_right, 'complex'}, - - ['k'] = {cursor_up, 'repeat'}, - ['j'] = {cursor_down, 'repeat'}, - ['K'] = {cursor_page_up, 'repeat'}, - ['J'] = {cursor_page_down, 'repeat'}, - ['UP'] = {cursor_up, 'repeat'}, - ['DOWN'] = {cursor_down, 'repeat'}, - ['Shift+UP'] = {cursor_page_up, 'repeat'}, - ['Shift+DOWN'] = {cursor_page_down, 'repeat'}, - ['PGUP'] = {cursor_page_up, 'repeat'}, - ['PGDWN'] = {cursor_page_down, 'repeat'}, - ['HOME'] = {cursor_start}, - ['END'] = {cursor_end}, - ['WHEEL_UP'] = {cursor_wheel_up, 'repeat'}, - ['WHEEL_DOWN'] = {cursor_wheel_down, 'repeat'}, - ['Shift+WHEEL_UP'] = {cursor_wheel_page_up, 'repeat'}, - ['Shift+WHEEL_DOWN'] = {cursor_wheel_page_down, 'repeat'}, - - ['Alt+k'] = {move_option_up, 'repeat'}, - ['Alt+j'] = {move_option_down, 'repeat'}, - ['Alt+K'] = {move_option_page_up, 'repeat'}, - ['Alt+J'] = {move_option_page_down, 'repeat'}, - ['Alt+UP'] = {move_option_up, 'repeat'}, - ['Alt+DOWN'] = {move_option_down, 'repeat'}, - ['Shift+Alt+UP'] = {move_option_page_up, 'repeat'}, - ['Shift+Alt+DOWN'] = {move_option_page_down, 'repeat'}, - ['Alt+PGUP'] = {move_option_page_up, 'repeat'}, - ['Alt+PGDWN'] = {move_option_page_down, 'repeat'}, - ['Alt+HOME'] = {move_option_start}, - ['Alt+END'] = {move_option_end}, - ['Alt+WHEEL_UP'] = {move_option_wheel_up, 'repeat'}, - ['Alt+WHEEL_DOWN'] = {move_option_wheel_down, 'repeat'}, - ['Shift+Alt+WHEEL_UP'] = {move_option_wheel_page_up, 'repeat'}, - ['Shift+Alt+WHEEL_DOWN'] = {move_option_wheel_page_down, 'repeat'}, + ['BS'] = {rt.prev_menu}, + ['/'] = {rt.start_search}, + ['Ctrl+s'] = {rt.toggle_menu_sort}, + ['Ctrl+R'] = {rt.reload_data}, + + ['ENTER'] = {rt.select_option}, + ['Ctrl+f'] = {rt.favourite_option}, + ['g'] = {rt.goto_option}, + ['i'] = {rt.open_option_info}, + ['?'] = {rt.open_option_info}, + ['Ctrl+p'] = {rt.goto_playing}, + + ['MBTN_LEFT'] = {rt.mouse_click_left, 'complex'}, + ['MBTN_RIGHT'] = {rt.mouse_click_right, 'complex'}, + + ['k'] = {rt.cursor_up, 'repeat'}, + ['j'] = {rt.cursor_down, 'repeat'}, + ['K'] = {rt.cursor_page_up, 'repeat'}, + ['J'] = {rt.cursor_page_down, 'repeat'}, + ['UP'] = {rt.cursor_up, 'repeat'}, + ['DOWN'] = {rt.cursor_down, 'repeat'}, + ['Shift+UP'] = {rt.cursor_page_up, 'repeat'}, + ['Shift+DOWN'] = {rt.cursor_page_down, 'repeat'}, + ['PGUP'] = {rt.cursor_page_up, 'repeat'}, + ['PGDWN'] = {rt.cursor_page_down, 'repeat'}, + ['HOME'] = {rt.cursor_start}, + ['END'] = {rt.cursor_end}, + ['WHEEL_UP'] = {rt.cursor_wheel_up, 'repeat'}, + ['WHEEL_DOWN'] = {rt.cursor_wheel_down, 'repeat'}, + ['Shift+WHEEL_UP'] = {rt.cursor_wheel_page_up, 'repeat'}, + ['Shift+WHEEL_DOWN'] = {rt.cursor_wheel_page_down, 'repeat'}, + + ['Alt+k'] = {rt.move_option_up, 'repeat'}, + ['Alt+j'] = {rt.move_option_down, 'repeat'}, + ['Alt+K'] = {rt.move_option_page_up, 'repeat'}, + ['Alt+J'] = {rt.move_option_page_down, 'repeat'}, + ['Alt+UP'] = {rt.move_option_up, 'repeat'}, + ['Alt+DOWN'] = {rt.move_option_down, 'repeat'}, + ['Shift+Alt+UP'] = {rt.move_option_page_up, 'repeat'}, + ['Shift+Alt+DOWN'] = {rt.move_option_page_down, 'repeat'}, + ['Alt+PGUP'] = {rt.move_option_page_up, 'repeat'}, + ['Alt+PGDWN'] = {rt.move_option_page_down, 'repeat'}, + ['Alt+HOME'] = {rt.move_option_start}, + ['Alt+END'] = {rt.move_option_end}, + ['Alt+WHEEL_UP'] = {rt.move_option_wheel_up, 'repeat'}, + ['Alt+WHEEL_DOWN'] = {rt.move_option_wheel_down, 'repeat'}, + ['Shift+Alt+WHEEL_UP'] = {rt.move_option_wheel_page_up, 'repeat'}, + ['Shift+Alt+WHEEL_DOWN'] = {rt.move_option_wheel_page_down, 'repeat'}, } binding_state.mappings.SEARCH = { - ['ANY_UNICODE'] = {search_input_char, 'complex'}, - ['BS'] = {search_input_bs, 'repeat'}, - ['DEL'] = {search_input_del, 'repeat'}, - - ['ENTER'] = {end_search}, - ['ESC'] = {cancel_search}, - ['Ctrl+c'] = {cancel_search}, - - ['LEFT'] = {search_cursor_left, 'repeat'}, - ['RIGHT'] = {search_cursor_right, 'repeat'}, - ['Ctrl+a'] = {search_cursor_start}, - ['Ctrl+e'] = {search_cursor_end}, + ['ANY_UNICODE'] = {rt.search_input_char, 'complex'}, + ['BS'] = {rt.search_input_bs, 'repeat'}, + ['DEL'] = {rt.search_input_del, 'repeat'}, + + ['ENTER'] = {rt.end_search}, + ['ESC'] = {rt.cancel_search}, + ['Ctrl+c'] = {rt.cancel_search}, + + ['LEFT'] = {rt.search_cursor_left, 'repeat'}, + ['RIGHT'] = {rt.search_cursor_right, 'repeat'}, + ['Ctrl+a'] = {rt.search_cursor_start}, + ['Ctrl+e'] = {rt.search_cursor_end}, } -- mpv does not process key-binding changes requested by script functions until @@ -1444,12 +506,12 @@ osc_visibility = mp.get_property_native('user-data/osc/visibility', 'auto') set_osc_visibility() mp.add_forced_key_binding('TAB', 'toggle-menu', toggle_menu) -set_key_mapping('MENU') +binding_state.active = binding_state.mappings['MENU'] set_key_bindings() mp.add_timeout(0, function() - load_data() + rt.load_data() state.depth = 0 - push_group_menu(catalogue:get('root')) + rt.push_group_menu(catalogue:get('root')) osd:redraw(state) end) diff --git a/rt.lua b/rt.lua new file mode 100644 index 0000000..f8b73c1 --- /dev/null +++ b/rt.lua @@ -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: ' .. + ' (/)' + + 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: ' .. + ' (/)' + 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 -- cgit v1.2.3-70-g09d2