summaryrefslogtreecommitdiff
path: root/rt.lua
diff options
context:
space:
mode:
Diffstat (limited to 'rt.lua')
-rw-r--r--rt.lua968
1 files changed, 968 insertions, 0 deletions
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: <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