-- Copyright 2025 David Vazgenovich Shakaryan local utils = require('mp.utils') local script_dir = mp.get_script_directory() local stream_prefix = mp.get_opt('iptv_menu.stream_prefix') -- font size is in units of osd height, which is scaled to 720 local font_size = 20 local osd = mp.create_osd_overlay('ass-events') local osd_lines = math.floor((720 / font_size) + 0.5) - 1 local osd_padding = math.floor((720 - (osd_lines * font_size)) / 2) local osd_cursor_glyph = '{\\p1\\pbo' .. math.floor(font_size / 5) .. '}' .. 'm 0 0 l ' .. math.ceil(font_size / 32) .. ' 0 ' .. math.ceil(font_size / 32) .. ' ' .. font_size .. ' 0 ' .. font_size .. '{\\p0}' local osd_bg = mp.create_osd_overlay('ass-events') osd_bg.z = -1 osd_bg.data = '{\\alpha&H50&\\c&H&\\pos(0,0)}' .. '{\\p1}m 0 0 l 9999 0 9999 720 0 720{\\p0}' local categories = {} local streams = {} local depth = 0 local menus = {} local key_bindings = {} local function load_json_file(path) local f = io.open(script_dir .. '/' .. path, 'r') local data = f:read('*all') f:close() return utils.parse_json(data) end local function load_data() categories = load_json_file('categories.json') for _, v in ipairs(categories) do v.type = 'category' v.name = v.category_name end streams = load_json_file('streams.json') for _, v in ipairs(streams) do v.type = 'stream' end end local function osd_menu_lines() if depth > 1 then -- leaves an extra line for padding between titles and options return osd_lines - depth else return osd_lines end end local function update_osd() local out = {} if depth > 1 then for i = 2, depth do local col = '999999' if menus[i].search_active then col = 'FF00' end out[#out+1] = '{\\c&H' .. col .. '&} ยป [' .. menus[i].title .. ']{\\c}' end out[#out+1] = ' ' -- space character for correct line height end local menu = menus[depth] for i = menu.page_pos, math.min( menu.page_pos + osd_menu_lines() - 1, #menu.options) do local opt = menu.options[i] local str = opt.name if opt.type == 'category' then str = '[' .. str .. ']' end if i == menu.cursor_pos and not menu.search_active then str = '{\\c&HFF00&}* ' .. str .. '{\\c}' elseif opt.type == 'category' then str = '{\\c&H99DDFF&}' .. str .. '{\\c}' end out[#out+1] = str end -- \q2 disables line wrapping osd.data = '{\\q2\\fs' .. font_size .. '\\pos(' .. osd_padding .. ',' .. osd_padding .. ')}' .. table.concat(out, '\\N') osd:update() osd_bg:update() end local function advance_cursor(n, opts) local menu = menus[depth] local lines = osd_menu_lines() local pos = math.max(1, math.min(menu.cursor_pos + n, #menu.options)) local top = menu.page_pos if opts and opts.advance_page then top = top + n end -- move page to keep selected option visible if pos < top then top = pos elseif pos > top + lines - 1 then top = pos - lines + 1 end top = math.max(1, math.min(top, #menu.options - lines + 1)) menu.cursor_pos = pos menu.page_pos = top update_osd() end local function next_option() advance_cursor(1) end local function prev_option() advance_cursor(-1) end local function next_page() advance_cursor(osd_menu_lines(), {advance_page=true}) end local function prev_page() advance_cursor(-osd_menu_lines(), {advance_page=true}) end local function first_option() advance_cursor(-math.huge) end local function last_option() advance_cursor(math.huge) end local function push_menu(t) local menu = { options={}, title=title, cursor_pos=1, page_pos=1, } if t then for k, v in pairs(t) do menu[k] = v end end depth = depth + 1 menus[depth] = menu end local function category_menu_options(category_id) local options = {} for _, v in ipairs(categories) do if tostring(v.parent_id) == category_id then options[#options+1] = v end end for _, v in ipairs(streams) do if v.category_id == category_id then options[#options+1] = v end end return options end local function add_category_menu(category_id, category_name) push_menu({ options=category_menu_options(category_id), title=category_name, }) update_osd() end local function play_stream(stream_id) local url = stream_prefix .. stream_id mp.commandv('loadfile', url) end local function select_option() local menu = menus[depth] local opt = menu.options[menu.cursor_pos] if opt.type == 'category' then add_category_menu(opt.category_id, opt.name) else play_stream(opt.stream_id) end end local function prev_menu() if depth > 1 then depth = depth - 1 update_osd() end end local function bind_key(key, name, func, opts) key_bindings[key] = name mp.add_forced_key_binding(key, name, func, opts) end local function unbind_keys() for _, name in pairs(key_bindings) do mp.remove_key_binding(name) end key_bindings = {} end local function split_search_text() local menu = menus[depth] return menu.search_text:sub(1, math.max(0, menu.search_text_cursor_pos - 1)), menu.search_text:sub(menu.search_text_cursor_pos) end local function search_update_osd() local bc, ac = split_search_text() menus[depth].title = 'Searching: ' .. bc .. osd_cursor_glyph .. ac update_osd() end local function search_menu_options_build(options, t) local menu = menus[depth] for _, v in ipairs(options) do if not t[v.type] then t[v.type] = {} end t[v.type][#t[v.type]+1] = v if v.type == 'category' then search_menu_options_build( category_menu_options(v.category_id), t) end end end local function search_menu_options(options) local t = {} search_menu_options_build(options, t) local ret = t.category or {} if t.stream then for _, v in ipairs(t.stream) do ret[#ret+1] = v end end return ret end local function search_update() local menu = menus[depth] local matches = {} for _, v in ipairs(menu.search_options) do if string.find(v.name, menu.search_text, 0, true) then matches[#matches+1] = v end end menu.options = matches search_update_osd() end local function search_input(event) local menu = menus[depth] if event.event ~= 'down' and event.event ~= 'repeat' then return end local bc, ac = split_search_text() menu.search_text = bc .. event.key_text .. ac menu.search_text_cursor_pos = menu.search_text_cursor_pos + 1 search_update() end local function search_backspace() local menu = menus[depth] if menu.search_text_cursor_pos == 1 then return end local bc, ac = split_search_text() menu.search_text = bc:sub(1, -2) .. ac menu.search_text_cursor_pos = menu.search_text_cursor_pos - 1 search_update() end local function search_delete() local menu = menus[depth] if menu.search_text_cursor_pos == #menu.search_text + 1 then return end local bc, ac = split_search_text() menu.search_text = bc .. ac:sub(2) search_update() end local function search_cursor_left() local menu = menus[depth] if menu.search_text_cursor_pos == 1 then return end menu.search_text_cursor_pos = menu.search_text_cursor_pos - 1 search_update_osd() end local function search_cursor_right() local menu = menus[depth] if menu.search_text_cursor_pos == #menu.search_text + 1 then return end menu.search_text_cursor_pos = menu.search_text_cursor_pos + 1 search_update_osd() end local bind_search_keys local bind_menu_keys local function search_start() local menu = menus[depth] if menu.type == 'search' then menu.search_active = true menu.search_text_cursor_pos = #menu.search_text + 1 menu.cursor_pos = 1 menu.page_pos = 1 search_update_osd() else push_menu({ type='search', search_active=true, search_options=search_menu_options(menu.options), search_text='', search_text_cursor_pos=1, }) search_update() end bind_search_keys() end local function search_finish() local menu = menus[depth] menu.search_active = false menu.title = 'Search results: ' .. menu.search_text update_osd() bind_menu_keys() end local function search_cancel() menus[depth].search_active = false depth = depth - 1 update_osd() bind_menu_keys() end function bind_search_keys() unbind_keys() local r = {repeatable=true} bind_key('ANY_UNICODE', 'search-input', search_input, {complex=true}) bind_key('ENTER', 'finish-search', search_finish) bind_key('ESC', 'cancel-search', search_cancel) bind_key('BS', 'search-backspace', search_backspace, r) bind_key('DEL', 'search-delete', search_delete, r) bind_key('LEFT', 'search-cursor-left', search_cursor_left, r) bind_key('RIGHT', 'search-cursor-right', search_cursor_right, r) end function bind_menu_keys() unbind_keys() local r = {repeatable=true} bind_key('DOWN', 'next-option', next_option, r) bind_key('UP', 'prev-option', prev_option, r) bind_key('PGDWN', 'next-page', next_page, r) bind_key('PGUP', 'prev-page', prev_page, r) bind_key('HOME', 'first-option', first_option) bind_key('END', 'last-option', last_option) bind_key('ENTER', 'select-option', select_option) bind_key('BS', 'prev-menu', prev_menu) bind_key('/', 'start-search', search_start) end local function toggle_menu() osd.hidden = not osd.hidden osd_bg.hidden = osd.hidden osd:update() osd_bg:update() if osd.hidden then unbind_keys() elseif menus[depth].search_active then bind_search_keys() else bind_menu_keys() end end mp.add_forced_key_binding('TAB', 'toggle-menu', toggle_menu) bind_menu_keys() load_data() add_category_menu('0') update_osd()