summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-23 00:15:34 -0800
committerDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-23 00:15:34 -0800
commitaf9a5c2c065be5fecf9fbbea6db58ad746e3a582 (patch)
tree5438014d714431869282604e5c44c4178a5b2d54
parent60b57132f163f41291b40c124aba240a392a1884 (diff)
downloadmpv-iptv-menu-af9a5c2c065be5fecf9fbbea6db58ad746e3a582.tar.gz
mpv-iptv-menu-af9a5c2c065be5fecf9fbbea6db58ad746e3a582.tar.xz
move routines to separate module
-rw-r--r--main.lua1092
-rw-r--r--rt.lua968
2 files changed, 1045 insertions, 1015 deletions
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: <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
-
-local function 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
-
-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: <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