-- Copyright 2025 David Vazgenovich Shakaryan local cacher = require('cacher') local config = require('config') local util = require('util') local _catalogue = require('catalogue') local _downloader = require('downloader') local _epg = require('epg') local _osd = require('osd') local _state = require('state') local _xc = require('xc') local mp_utils = require('mp.utils') local script_name = mp.get_script_name() local state = _state.new() local binding_state = {mappings = {}, active = {}} local click_state = {} local osc_visibility local downloader = _downloader.new({limit = 5}) local xc = _xc.new({ server = mp.get_opt('iptv_menu.xc_server'), user = mp.get_opt('iptv_menu.xc_user'), pass = mp.get_opt('iptv_menu.xc_pass'), }) xc = cacher.wrap(xc, { directory = config.cache_dir, prefix = (xc.server:gsub('%W', '_')), time = 24*60*60, functions = { get_live_categories = true, get_live_streams = true, get_vod_categories = true, get_vod_streams = true, get_vod_info = true, get_series_categories = true, get_series = true, get_series_info = true, get_epg = true, }, }) local catalogue = _catalogue.new() local epg = _epg.new() local osd local function dl_img(url, path, cb) downloader:schedule(url, path, function(success, _, path) if success then cb(path) else osd:flash_error('Image download failed') end end) end osd = _osd.new({ img_path_func = function(url, cb) local path = mp_utils.join_path( config.img_dir, url:gsub('%W', '_')) if not mp_utils.file_info(path) and cb then dl_img(url, path, cb) end return path end, img_fail_cb = function() osd:flash_error('Image load failed') 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_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 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 local path = {} local curr = obj while curr.parent_id and curr.parent_id ~= 'root' and catalogue:get(curr.parent_id) do curr = catalogue:get(curr.parent_id) path[#path+1] = curr end obj = util.copy_table(obj) add_programme(obj, time) local c = group_count(obj) if c then obj.info = tostring(c) end if #path > 0 and curr.parent_id == 'root' then util.reverse(path) 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 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 if opt.path then for i = 1, #opt.path do cursor_to_object(opt.path[i].id) push_group_menu(opt.path[i]) end end cursor_to_object(opt.id) osd:dirty() end local function goto_playing() if not state.playing_id then return end local obj = catalogue:get(state.playing_id) if not obj then return end local path = {} local curr = obj while curr.parent_id and curr.parent_id ~= 'root' and catalogue:get(curr.parent_id) do curr = catalogue:get(curr.parent_id) path[#path+1] = curr end if #path == 0 or curr.parent_id ~= 'root' 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(obj.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: ' .. ' (/)' 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: ' .. ' (/)' 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 local function mouse_has_drifted(x1, y1, x2, y2) return math.abs(x1 - x2) > config.click_max_drift or math.abs(y1 - y2) > config.click_max_drift end -- when mpv registers a double-click, the second click triggers both -- double-click and click events. instead of identifying and ignoring this -- second click event, we can implement double-click detection ourselves for -- more control and to bypass some mpv inconsistencies. local function handle_mouse_click(ev, id) if ev.canceled then click_state = {} return end local time = mp.get_time() local clk = click_state local mpos = osd.mpos if ev.event == 'down' then clk.ct, clk.ck = time, ev.key_name clk.cx, clk.cy, clk.ci = mpos.x, mpos.y, id return end if ev.event ~= 'up' or not clk.ct then return end if time - clk.ct > config.click_timeout or clk.ck ~= ev.key_name or clk.ci ~= id or mouse_has_drifted(mpos.x, mpos.y, clk.cx, clk.cy) then click_state = {} return end local dbl = clk.pt and clk.ct - clk.pt <= config.click_dbl_time and clk.pk == ev.key_name and clk.pi == id and not mouse_has_drifted(clk.cx, clk.cy, clk.px, clk.py) click_state = dbl and {} or { pt = clk.ct, pk = ev.key_name, px = clk.cx, py = clk.cy, pi = id, } return ev.key_name, dbl end local function process_mouse_click(ev) local ms = osd.mstate if not ms then return end local area local val if ms.target == 'scrollbar' then area = 'scrollbar' val = ms.ratio elseif ms.target == 'menu' then area = 'menu' val = ms.option_line end local key, dbl = handle_mouse_click(ev, area == 'menu' and val or nil) return key, dbl, area, val end local function mouse_click_left_menu(dbl, line) if line == 0 then return end -- title if line < 0 then if not dbl then return end if line == -1 then set_cursor(1) else state.depth = state.depth + line + 1 osd:dirty() end return end local menu = state:menu() local pos = menu.view_top + line - 1 if dbl then if pos ~= menu.cursor then return end select_option() else if pos > #menu.options then return end set_cursor(pos) end end local function mouse_click_left_scrollbar(dbl, ratio) if not dbl then return end -- set_cursor handles out-of-bounds moves (when ratio == 1) set_cursor( math.floor(ratio * #state:menu().options) + 1, {centre = true}) end local function mouse_click_left(ev) local key, dbl, area, val = process_mouse_click(ev) if not key then return end if area == 'menu' then return mouse_click_left_menu(dbl, val) elseif area == 'scrollbar' then return mouse_click_left_scrollbar(dbl, val) end end local function mouse_click_right_menu(dbl, line) if not dbl or line < 1 then return end local menu = state:menu() local pos = menu.view_top + line - 1 if pos > #menu.options then return end open_option_info(menu.options[pos]) end local function mouse_click_right(ev) local key, dbl, area, val = process_mouse_click(ev) if not key then return end if area == 'menu' then return mouse_click_right_menu(dbl, val) 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_up, 'repeat'}, ['WHEEL_DOWN'] = {cursor_down, 'repeat'}, ['Shift+WHEEL_UP'] = {cursor_page_up, 'repeat'}, ['Shift+WHEEL_DOWN'] = {cursor_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_up, 'repeat'}, ['Alt+WHEEL_DOWN'] = {move_option_down, 'repeat'}, ['Shift+Alt+WHEEL_UP'] = {move_option_page_up, 'repeat'}, ['Shift+Alt+WHEEL_DOWN'] = {move_option_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}, } -- mpv does not process key-binding changes requested by script functions until -- those functions return. in the meantime, while the function is running, mpv -- discards any key presses for keys that were not already bound prior to the -- start of function execution. similarly, any pending key presses (for keys -- that were bound) are discarded if the binding changes during function -- execution. -- -- in other words, we get an inconsistent result where pressing a key while a -- function is running triggers the bound function upon completion if that -- binding remains the same, but ignores such key presses when our function -- changes the corresponding binding or adds a new one. we can avoid this by -- leaving our keys bound to a common function and building our own logic to -- route keys per the current state. local function handle_key(ev) local t = binding_state.active[ev.key_name] if not t and ev.key_text then t = binding_state.active['ANY_UNICODE'] end local f = t and t[1] if not f then return end local flag = t[2] if flag == 'complex' then f(ev) elseif ev.event == 'down' or (ev.event == 'repeat' and flag == 'repeat') then f() end osd:flush(state) end -- uses enable-section and disable-section to disable builtin key bindings -- while the OSD is visible. these commands are technically deprecated for -- non-internal use, but they still work, and there doesn't appear to be -- another way apart from setting an override for each individual key. -- -- nonexistent `nodrag' section is enabled to disable VO dragging while the -- menu is open. local function set_key_bindings() if osd:is_hidden() then if binding_state.bound then mp.remove_key_binding('mouse_move') mp.remove_key_binding('unmapped') mp.command_native({'disable-section', 'nodrag'}) mp.command_native({ 'enable-section', 'default', 'allow-hide-cursor+allow-vo-dragging'}) binding_state.bound = false end return end if not binding_state.bound then mp.command_native({'disable-section', 'default'}) mp.command_native({'enable-section', 'nodrag'}) mp.add_forced_key_binding('MOUSE_MOVE', 'mouse_move') -- noisy mp.add_forced_key_binding( 'UNMAPPED', 'unmapped', handle_key, {complex = true}) binding_state.bound = true end end local function set_osc_visibility() local v = osd:is_hidden() and osc_visibility or 'never' mp.command_native({'script-message', 'osc-visibility', v, ''}) end local function toggle_menu() osd:toggle_hidden() if osc_visibility ~= 'never' then set_osc_visibility() end set_key_bindings() end local function bind_click(f) mp.command_native({'enable-section', 'click-nodrag'}) mp.add_forced_key_binding( 'MBTN_LEFT', 'click', function(ev) if process_mouse_click(ev) then f() end end, {complex = true}) mp.add_forced_key_binding('MBTN_LEFT_DBL', 'click-dbl') end local function unbind_click() mp.remove_key_binding('click') mp.remove_key_binding('click-dbl') mp.command_native({'disable-section', 'click-nodrag'}) end local btn_timer; btn_timer = mp.add_periodic_timer(0.1, function() if osd.mpos and mp.get_time() - osd.mpos_time > config.btn_timeout then osd:show_menu_btn(false) btn_timer:kill() end end, true) -- disabled mp.observe_property('mouse-pos', 'native', function(_, mpos) if not mpos then return end local ps = osd.mstate or {} local ms = osd:set_mpos(mpos) if not ms then return end -- mpv normally sends a cancel event when the mouse drifts before a -- click is released, but only for a first click and not for the second -- click of what mpv internally considers a double-click. this -- replicates that drift detection behaviour, allowing for consistency -- on double-clicks. local clk = click_state if clk.ct and mouse_has_drifted(mpos.x, mpos.y, clk.cx, clk.cy) then click_state = {} end if not ms.over_btn_area ~= not ps.over_btn_area then osd:show_menu_btn(ms.over_btn_area) elseif ms.over_btn_area and osd.menu_btn.hidden then osd:show_menu_btn(true) end if (ms.target == 'menu_btn') ~= (ps.target == 'menu_btn') then if ms.target == 'menu_btn' then bind_click(toggle_menu) else unbind_click() end end if ms.over_btn_area and ms.target ~= 'menu_btn' then btn_timer:resume() else btn_timer:kill() end end) mp.observe_property('user-data/osc/visibility', 'native', function(_, val) if val and (osd:is_hidden() or val ~= 'never') then osc_visibility = val end end) mp.observe_property('osd-dimensions', 'native', function(_, val) osd:resize(val.w, val.h) osd:redraw(state) -- after a resize, mpv does not update the mouse coordinates until the -- mouse is moved. if the mouse was previously over a clickable element -- and we do nothing, a click would trigger that element regardless of -- mouse position. since we cannot get the new position, we instead -- wipe mouse state on resize, clearing it until the next move. osd:show_menu_btn(false) osd:set_mpos(nil) click_state = {} unbind_click() btn_timer:kill() end) mp.register_event('start-file', function() state.playing_id = mp.get_opt('iptv_menu.playing_id') osd:redraw(state) end) mp.register_event('end-file', function() state.playing_id = nil osd:redraw(state) end) mp.register_event('shutdown', function() osd:destroy() end) state:push_menu({title = 'mpv-iptv-menu'}) 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') set_key_bindings() mp.add_timeout(0, function() load_data() state.depth = 0 push_group_menu(catalogue:get('root')) osd:redraw(state) end)