summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-29 21:48:19 -0800
committerDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-29 21:48:19 -0800
commit28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d (patch)
tree16d25a670867ba56a53ea07afacd780b07275d8c
parentd59d49480e2981345fe173f741d6b0ad4a2d2320 (diff)
downloadmpv-iptv-menu-28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d.tar.gz
mpv-iptv-menu-28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d.tar.xz
move application-aware utility functions to separate module
-rw-r--r--main.lua2
-rw-r--r--osd.lua6
-rw-r--r--rt.lua453
-rw-r--r--rx.lua374
-rw-r--r--util.lua34
5 files changed, 460 insertions, 409 deletions
diff --git a/main.lua b/main.lua
index 71abee8..cadece7 100644
--- a/main.lua
+++ b/main.lua
@@ -4,6 +4,7 @@ local cacher = require('cacher')
local config = require('config')
local input = require('input')
local rt = require('rt')
+local rx = require('rx')
local util = require('util')
local _catalogue = require('catalogue')
local _downloader = require('downloader')
@@ -75,6 +76,7 @@ local ctx = {
xc = xc,
}
rt.init(state, osd, ctx)
+rx.init(state, osd, ctx)
mp.observe_property('mouse-pos', 'native', function(_, mpos)
input.update_mpos(mpos)
diff --git a/osd.lua b/osd.lua
index 87da3ab..2a05018 100644
--- a/osd.lua
+++ b/osd.lua
@@ -257,7 +257,7 @@ function mt:option_text(opt, info)
str = col .. '[' .. str .. ']'
end
- if opt.programme and opt.type == 'programme' and opt.active then
+ if opt.programme and opt.active then
str = str .. ' ' .. self:draw_progress_bar(
opt.programme, colour.progress_bar_fg_active,
colour.progress_bar_bg_active)
@@ -268,9 +268,9 @@ function mt:option_text(opt, info)
str = str .. colour.info .. ' (' .. asscape(opt_info) .. ')'
end
- if opt.programme and opt.type ~= 'programme' then
+ if opt.active_programme then
str = str .. ' ' .. self:draw_progress_bar(
- opt.programme,
+ opt.active_programme,
info.selected and colour.progress_bar_fg_active or
colour.progress_bar_fg,
info.selected and colour.progress_bar_bg_active or
diff --git a/rt.lua b/rt.lua
index 77e6ff7..1cdf777 100644
--- a/rt.lua
+++ b/rt.lua
@@ -2,6 +2,7 @@
local config = require('config')
local input = require('input')
+local rx = require('rx')
local util = require('util')
local rt = {}
@@ -30,68 +31,10 @@ local function cache_miss_status_msg(str)
end
local function series_children(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
+ return rx.series_children(
+ series,
+ ctx.xc:with_opts('get_series_info', series.series_id,
+ cache_miss_status_msg('Loading series info...')))
end
local function catalogue_add_section(sect, cats, elems)
@@ -278,7 +221,7 @@ function rt.cursor_wheel_page_down()
{keep_offset = true})
end
-local function cursor_to_object(id)
+local function cursor_to_id(id)
for i, v in ipairs(state:menu().options) do
if v.id == id then
rt.set_cursor(i, {centre = true})
@@ -361,93 +304,9 @@ function rt.move_option_wheel_page_down()
{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 menu_option_mt = {
- __index = function(t, k)
- local v = t._v
-
- if k == 'info' then
- if v.type == 'group' and not v.hide_count then
- local c = ctx.catalogue:group_count(v)
- local ret = c and tostring(c) or ''
- if not v.count_f then
- rawset(t, 'info', ret)
- end
- return ret
- elseif v.epg_channel_id then
- local time = osd.redraw_time
- if t._exp and time < t._exp then
- return t._info
- end
-
- local prog = ctx.epg:scheduled_programme(
- v.epg_channel_id, time)
- local ret = prog and prog.title or ''
- local exp = prog and prog.stop
- rawset(t, 'programme', prog)
-
- if not prog then
- prog = ctx.epg:next_programme(
- v.epg_channel_id, time)
- exp = prog and prog.start
- end
-
- rawset(t, exp and '_info' or 'info', ret)
- rawset(t, '_exp', exp)
- return ret
- end
- end
-
- return v[k]
- end,
-}
-
-local function group_menu_options(group)
- local options = {}
- for i, v in ipairs(ctx.catalogue:group_children(group)) do
- local t = setmetatable({_v = v}, menu_option_mt)
-
- if group.id == 'favourites' then
- local path = ctx.catalogue:path_from_root(v)
- if path then
- t.path = path
- end
- end
-
- options[i] = t
- end
- return options
-end
-
function rt.push_group_menu(group)
state:push_menu({
- options = group_menu_options(group),
+ options = rx.menu_options_group(group),
title = group.name,
type = 'group',
group_id = group.id,
@@ -455,9 +314,7 @@ function rt.push_group_menu(group)
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.
+-- existing menu options are never removed, even if unfavourited.
local function refresh_favourites_menu()
local menu = state:menu()
local opt = menu.options[menu.cursor]
@@ -466,41 +323,16 @@ local function refresh_favourites_menu()
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
+ menu.options = util.stable_kmerge(
+ menu.options,
+ rx.menu_options_group(ctx.catalogue:get(menu.group_id)),
+ 'id')
if sorted then
- menu:set_sort(true, sort_options)
+ menu:set_sort(true, rx.sort_menu_options)
end
if opt then
- cursor_to_object(opt.id)
+ cursor_to_id(opt.id)
end
end
@@ -584,10 +416,10 @@ function rt.goto_option()
end
for i = 1, #opt.path do
- cursor_to_object(opt.path[i].id)
+ cursor_to_id(opt.path[i].id)
rt.push_group_menu(opt.path[i])
end
- cursor_to_object(opt.id)
+ cursor_to_id(opt.id)
osd:dirty()
end
@@ -602,231 +434,82 @@ function rt.goto_playing()
state.depth = 1
for i = #path, 1, -1 do
- cursor_to_object(path[i].id)
+ cursor_to_id(path[i].id)
rt.push_group_menu(path[i])
end
- cursor_to_object(id)
+ cursor_to_id(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
+local function open_option_programme_info(opt, img_url)
+ local prog = opt.programme
+ local options = rx.menu_options_programme_info(prog)
+ if not options then
+ return
end
- local menu = state:push_menu({
+ state:push_menu({
options = options,
- title = 'Programme: ' .. prog.title,
- type = 'epg',
+ title = 'Programme Info: ' .. prog.title,
+ type = 'info',
+ img_url = img_url,
})
- if img_url then
- menu.img_url = img_url
- end
osd:dirty()
end
-local epg_programme_mt = {
- __index = function(t, k)
- if k == 'active' then
- local time = osd.redraw_time
- if t._exp and time < t._exp then
- return t._active
- end
-
- local v = t.programme
- local ret = time >= v.start and time < v.stop
- local exp = time < v.start and v.start or
- ret and v.stop or nil
- rawset(t, exp and '_active' or 'active', ret)
- rawset(t, '_exp', exp)
- return ret
- end
- end,
-}
-
-local function open_option_epg(opt)
+local function open_option_channel_epg(opt)
local ch = opt.epg_channel_id:lower()
- local progs = ctx.epg:channel_programmes(ch)
- if not progs then
+ local options, idx = rx.menu_options_channel_epg(ch)
+ if not options then
return
end
- local options = {}
- local idx
- local time = os.time()
- for i, v in ipairs(progs) do
- local prog = {
- name = os.date('%a %d %b %H:%M', v.start) .. ' ' ..
- os.date('%H:%M', v.stop) .. ' ' .. v.title,
- type = 'programme',
- info = v.desc,
- programme = v,
- }
-
- if not idx and time < v.stop then
- idx = i
- end
-
- options[i] = setmetatable(prog, epg_programme_mt)
- end
- idx = idx or #options
-
- local menu = state:push_menu({
+ state:push_menu({
options = options,
title = 'EPG: ' .. opt.name .. ' (' .. ch .. ')',
type = 'epg',
+ img_url = opt.img_url,
})
- if opt.img_url then
- menu.img_url = opt.img_url
- end
rt.set_cursor(idx, {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
+local function open_option_entry_info(title, info)
+ local options, img = rx.menu_options_entry_info(info)
+ if not options 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 = {
+ state:push_menu({
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)
+ img_url = img,
+ })
osd:dirty()
end
local function open_option_movie_info(opt)
local info = ctx.xc:with_opts('get_vod_info', opt.stream_id,
cache_miss_status_msg('Loading movie info...'))
- open_option_title_info('Movie Info: ' .. opt.name, info)
+ open_option_entry_info('Movie Info: ' .. opt.name, info)
end
local function open_option_series_info(opt)
local info = ctx.xc:with_opts('get_series_info', opt.series_id,
cache_miss_status_msg('Loading series info...'))
- open_option_title_info('Series Info: ' .. opt.name, info)
+ open_option_entry_info('Series Info: ' .. opt.name, info)
end
local function open_option_season_info(opt)
- open_option_title_info(
+ open_option_entry_info(
'Season Info: ' .. opt.name,
{info = opt.info_data})
end
local function open_option_episode_info(opt)
- open_option_title_info(
+ open_option_entry_info(
'Episode Info: ' .. opt.name,
{info = opt.info_data})
end
@@ -838,10 +521,10 @@ function rt.open_option_info(opt)
return
end
- if menu.type == 'epg' and opt.programme then
- open_epg_programme(opt.programme, menu.img_url)
+ if opt.programme then
+ open_option_programme_info(opt, menu.img_url)
elseif opt.epg_channel_id then
- open_option_epg(opt)
+ open_option_channel_epg(opt)
elseif opt.group_type == 'series' then
open_option_series_info(opt)
elseif opt.group_type == 'season' then
@@ -853,48 +536,6 @@ function rt.open_option_info(opt)
end
end
-local function search_menu_options_build(options, t, path)
- for _, v in ipairs(options) do
- 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 v.children 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 = {}}
-
- local opts = {}
- for i, v in ipairs(options) do
- -- menu options may contain dynamic data that is updated on
- -- redraw. using a proxy table instead of copying allows a
- -- single update to target both the source and search menus.
- opts[i] = setmetatable({}, {__index = v})
- end
-
- -- empty table is needed to shadow existing path from proxied table
- search_menu_options_build(opts, 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
@@ -981,7 +622,7 @@ function rt.start_search()
state:push_menu({
title = title,
type = 'search',
- options = search_menu_options(menu.options),
+ options = rx.menu_options_search(menu.options),
search_active = true,
})
end
@@ -1023,7 +664,7 @@ function rt.toggle_menu_sort()
return
end
- menu:set_sort(not menu.sorted, sort_options)
+ menu:set_sort(not menu.sorted, rx.sort_menu_options)
osd:dirty()
end
diff --git a/rx.lua b/rx.lua
new file mode 100644
index 0000000..007ca13
--- /dev/null
+++ b/rx.lua
@@ -0,0 +1,374 @@
+-- Copyright 2025 David Vazgenovich Shakaryan
+
+local util = require('util')
+
+local rx = {}
+
+local state
+local osd
+local ctx
+
+function rx.init(_state, _osd, _ctx)
+ state = _state
+ osd = _osd
+ ctx = _ctx
+end
+
+function rx.series_children(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 entry_mt = {
+ __index = function(t, k)
+ local v = t._v
+
+ if k == 'info' then
+ if v.type == 'group' and not v.hide_count then
+ local c = ctx.catalogue:group_count(v)
+ local ret = c and tostring(c) or ''
+ if not v.count_f then
+ rawset(t, 'info', ret)
+ end
+ return ret
+ elseif v.epg_channel_id then
+ local time = osd.redraw_time
+ if t._exp and time < t._exp then
+ return t._info
+ end
+
+ local prog = ctx.epg:scheduled_programme(
+ v.epg_channel_id, time)
+ local ret = prog and prog.title or ''
+ local exp = prog and prog.stop
+ rawset(t, 'active_programme', prog)
+
+ if not prog then
+ prog = ctx.epg:next_programme(
+ v.epg_channel_id, time)
+ exp = prog and prog.start
+ end
+
+ rawset(t, exp and '_info' or 'info', ret)
+ rawset(t, '_exp', exp)
+ return ret
+ end
+ end
+
+ return v[k]
+ end,
+}
+
+function rx.menu_options_group(group)
+ local options = {}
+ for i, v in ipairs(ctx.catalogue:group_children(group)) do
+ local t = setmetatable({_v = v}, entry_mt)
+
+ if group.id == 'favourites' then
+ local path = ctx.catalogue:path_from_root(v)
+ if path then
+ t.path = path
+ end
+ end
+
+ options[i] = t
+ end
+ return options
+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
+
+function rx.menu_options_entry_info(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 img = util.strip_ne(info.cover_big) or util.strip_ne(info.cover)
+ return options, img
+end
+
+local programme_mt = {
+ __index = function(t, k)
+ if k == 'active' then
+ local time = osd.redraw_time
+ if t._exp and time < t._exp then
+ return t._active
+ end
+
+ local v = t.programme
+ local ret = time >= v.start and time < v.stop
+ local exp = time < v.start and v.start or
+ ret and v.stop or nil
+ rawset(t, exp and '_active' or 'active', ret)
+ rawset(t, '_exp', exp)
+ return ret
+ end
+ end,
+}
+
+function rx.menu_options_channel_epg(ch)
+ local progs = ctx.epg:channel_programmes(ch)
+ if not progs then
+ return
+ end
+
+ local options = {}
+ local idx
+ local time = os.time()
+ for i, v in ipairs(progs) do
+ local prog = {
+ name = os.date('%a %d %b %H:%M', v.start) .. ' ' ..
+ os.date('%H:%M', v.stop) .. ' ' .. v.title,
+ type = 'programme',
+ info = v.desc,
+ programme = v,
+ }
+
+ if not idx and time < v.stop then
+ idx = i
+ end
+
+ options[i] = setmetatable(prog, programme_mt)
+ end
+
+ return options, idx or #options
+end
+
+function rx.menu_options_programme_info(prog)
+ 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
+
+ return options
+end
+
+local function menu_options_search_build(options, t, path)
+ for _, v in ipairs(options) do
+ 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 v.children then
+ local path = util.copy_table(path)
+ path[#path+1] = v
+ menu_options_search_build(
+ rx.menu_options_group(v), t, path)
+ end
+ end
+end
+
+function rx.menu_options_search(opts)
+ local t = {categories = {}, elements = {}}
+
+ local options = {}
+ for i, v in ipairs(opts) do
+ -- menu options may contain dynamic data that is updated on
+ -- redraw. using a proxy table instead of copying allows a
+ -- single update to target both the source and search menus.
+ options[i] = setmetatable({}, {__index = v})
+ end
+
+ -- empty table is needed to shadow existing path from proxied table
+ menu_options_search_build(options, t, {})
+
+ -- display categories first
+ options = t.categories
+ for _, v in ipairs(t.elements) do
+ options[#options+1] = v
+ end
+ return options
+end
+
+function rx.sort_menu_options(opts)
+ local scores = {}
+ for _, v in ipairs(opts) 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(opts, 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
+
+return rx
diff --git a/util.lua b/util.lua
index 99f8fe8..04e1bf6 100644
--- a/util.lua
+++ b/util.lua
@@ -91,4 +91,38 @@ function util.write_json_file(path, data)
f:close()
end
+-- merges tables `t' and `u', using key `k' for identity. the order from `u' is
+-- always respected, preserving the relative order from `t' when possible.
+function util.stable_kmerge(t, u, k)
+ local pos = {}
+ for i, v in ipairs(u) do
+ pos[v[k]] = i
+ end
+
+ local res = {}
+ local seen = {}
+ local function append(v)
+ if not seen[v[k]] then
+ res[#res+1] = v
+ seen[v[k]] = true
+ end
+ end
+
+ local ind = 1
+ for _, v in ipairs(t) do
+ if pos[v[k]] then
+ while ind <= pos[v[k]] do
+ append(u[ind])
+ ind = ind + 1
+ end
+ end
+ append(v)
+ end
+ for i = ind, #u do
+ append(u[i])
+ end
+
+ return res
+end
+
return util