-- 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_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 key_bindings = {} local categories = {} local streams = {} local depth = 0 local menus = {} local menu_top = {} local menu_pos = {} local menu_titles = {} local search_text = '' local search_active = false 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' 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 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 out[#out+1] = '{\\c&H999999&} ยป [' .. menu_titles[i] .. ']{\\c}' end out[#out+1] = ' ' -- space character for correct line height end for i = menu_top[depth], math.min( menu_top[depth] + osd_menu_lines() - 1, #menus[depth]) do local opt = menus[depth][i] local str if opt.type == 'category' then str = '[' .. opt.category_name .. ']' else str = opt.name end if i == menu_pos[depth] and not 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) pos = math.max(1, math.min(menu_pos[depth] + n, #menus[depth])) top = menu_top[depth] 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 + osd_menu_lines() - 1 then top = pos - osd_menu_lines() + 1 end top = math.max(1, math.min(top, #menus[depth] - osd_menu_lines() + 1)) menu_pos[depth] = pos menu_top[depth] = 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(menu, title) depth = depth + 1 menus[depth] = menu or {} menu_top[depth] = 1 menu_pos[depth] = 1 menu_titles[depth] = title end local function add_category_menu(category_id, category_name) local menu = {} for _, v in ipairs(categories) do if tostring(v.parent_id) == category_id then menu[#menu+1] = v end end for _, v in ipairs(streams) do if v.category_id == category_id then menu[#menu+1] = v end end push_menu(menu, 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 opt = menus[depth][menu_pos[depth]] if opt.type == 'category' then add_category_menu(opt.category_id, opt.category_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 search_update() matches = {} for _, v in ipairs(menus[depth-1]) do local name if v.type == 'category' then name = v.category_name else name = v.name end if string.find(name, search_text, 0, true) then matches[#matches+1] = v end end menus[depth] = matches menu_titles[depth] = 'Searching: ' .. search_text end local function search_input(event) if not (event.event == 'down' or event.event == 'repeat') then return end search_text = search_text .. event.key_text search_update() update_osd() end local function search_backspace() search_text = search_text:sub(1, -2) search_update() update_osd() end local bind_search_keys local bind_menu_keys local function search_start() search_active = true push_menu() search_text = '' search_update() update_osd() bind_search_keys() end local function search_finish() search_active = false menu_titles[depth] = 'Search results: ' .. search_text update_osd() bind_menu_keys() end local function search_cancel() search_active = false search_text = '' depth = depth - 1 update_osd() bind_menu_keys() end function bind_search_keys() unbind_keys() 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) end function bind_menu_keys() unbind_keys() local repeatable = {repeatable=true} bind_key('DOWN', 'next-option', next_option, repeatable) bind_key('UP', 'prev-option', prev_option, repeatable) bind_key('PGDWN', 'next-page', next_page, repeatable) bind_key('PGUP', 'prev-page', prev_page, repeatable) 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 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()