-- 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 categories = {} local streams = {} local depth = 0 local menus = {} local search_text = '' local search_active = false 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 out[#out+1] = '{\\c&H999999&} ยป [' .. 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 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(options, title) menu = { title=title, options=options or {}, cursor_pos=1, page_pos=1, } depth = depth + 1 menus[depth] = menu end local function add_category_menu(category_id, category_name) 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 push_menu(options, 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 search_update() local matches = {} for _, v in ipairs(menus[depth-1].options) do if string.find(v.name, search_text, 0, true) then matches[#matches+1] = v end end local menu = menus[depth] menu.options = matches menu.title = 'Searching: ' .. search_text end local function search_input(event) if event.event ~= 'down' and 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 menus[depth].title = '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()