---@alias OpenCommandMenuOptions {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]} ---@param data MenuData ---@param opts? OpenCommandMenuOptions function open_command_menu(data, opts) opts = opts or {} local menu local function run_command(command) if type(command) == 'table' then ---@diagnostic disable-next-line: deprecated mp.commandv(unpack(command)) else mp.command(tostring(command)) end end local function callback(event) if type(menu.root.callback) == 'table' then ---@diagnostic disable-next-line: deprecated mp.commandv(unpack(itable_join({'script-message-to'}, menu.root.callback, {utils.format_json(event)}))) elseif event.type == 'activate' then -- Modifiers and actions are not available on basic non-callback mode menus. -- `alt` modifier should activate without closing the menu. if (event.modifiers == 'alt' or not event.modifiers) and not event.action then run_command(event.value) end -- Convention: Only pure item activations should close the menu. -- Using modifiers or triggering item actions should not. if not event.keep_open and not event.modifiers and not event.action then menu:close() end end end ---@type MenuOptions local menu_opts = table_assign_props({}, opts, {'mouse_nav'}) menu = Menu:open(data, callback, menu_opts) if opts.submenu then menu:activate_menu(opts.submenu) end return menu end ---@param opts? OpenCommandMenuOptions function toggle_menu_with_items(opts) if Menu:is_open('menu') then Menu:close() else open_command_menu({type = 'menu', items = get_menu_items(), search_submenus = true}, opts) end end ---@alias TrackEventRemove {type: 'remove' | 'delete', index: number; value: any;} ---@alias TrackEventReload {type: 'reload', index: number; value: any;} ---@param opts {type: string; title: string; list_prop: string; active_prop?: string; footnote?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[]; actions_place?: 'inside'|'outside'; on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: TrackEventRemove); on_delete?: fun(event: TrackEventRemove); on_reload?: fun(event: TrackEventReload); on_key?: fun(event: MenuEventKey, close: fun())} function create_self_updating_menu_opener(opts) return function() if Menu:is_open(opts.type) then Menu:close() return end local list = mp.get_property_native(opts.list_prop) local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil local menu local function update() menu:update_items(opts.serializer(list, active)) end local ignore_initial_list = true local function handle_list_prop_change(name, value) if ignore_initial_list then ignore_initial_list = false else list = value update() end end local ignore_initial_active = true local function handle_active_prop_change(name, value) if ignore_initial_active then ignore_initial_active = false else active = value update() end end local function cleanup_and_close() mp.unobserve_property(handle_list_prop_change) mp.unobserve_property(handle_active_prop_change) menu:close() end local initial_items, selected_index = opts.serializer(list, active) ---@type MenuAction[] local actions = opts.actions or {} if opts.on_move then actions[#actions + 1] = { name = 'move_up', icon = 'arrow_upward', label = t('Move up') .. ' (ctrl+up/pgup/home)', filter_hidden = true, } actions[#actions + 1] = { name = 'move_down', icon = 'arrow_downward', label = t('Move down') .. ' (ctrl+down/pgdwn/end)', filter_hidden = true, } end if opts.on_reload then actions[#actions + 1] = {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'} end if opts.on_remove or opts.on_delete then local label = (opts.on_remove and t('Remove') or t('Delete')) .. ' (del)' if opts.on_remove and opts.on_delete then label = t('Remove') .. ' (' .. t('%s to delete', 'del, ctrl+del') .. ')' end actions[#actions + 1] = {name = 'remove', icon = 'delete', label = label} end function remove_or_delete(index, value, menu_id, modifiers) if opts.on_remove and opts.on_delete then local method = modifiers == 'ctrl' and 'delete' or 'remove' local handler = method == 'delete' and opts.on_delete or opts.on_remove if handler then handler({type = method, value = value, index = index}) end elseif opts.on_remove or opts.on_delete then local method = opts.on_delete and 'delete' or 'remove' local handler = opts.on_delete or opts.on_remove if handler then handler({type = method, value = value, index = index}) end end end -- Items and active_index are set in the handle_prop_change callback, since adding -- a property observer triggers its handler immediately, we just let that initialize the items. menu = Menu:open({ type = opts.type, title = opts.title, footnote = opts.footnote, items = initial_items, item_actions = actions, item_actions_place = opts.actions_place, selected_index = selected_index, on_move = opts.on_move and 'callback' or nil, on_paste = opts.on_paste and 'callback' or nil, }, function(event) if event.type == 'activate' then if (event.action == 'move_up' or event.action == 'move_down') and opts.on_move then local to_index = event.index + (event.action == 'move_up' and -1 or 1) if to_index >= 1 and to_index <= #menu.current.items then opts.on_move({ type = 'move', from_index = event.index, to_index = to_index, menu_id = menu.current.id, }) menu:select_index(to_index) if not event.is_pointer then menu:scroll_to_index(to_index, nil, true) end end elseif event.action == 'reload' and opts.on_reload then opts.on_reload({type = 'reload', index = event.index, value = event.value}) elseif event.action == 'remove' and (opts.on_remove or opts.on_delete) then remove_or_delete(event.index, event.value, event.menu_id, event.modifiers) else opts.on_activate(event --[[@as MenuEventActivate]]) if not event.modifiers and not event.action then cleanup_and_close() end end elseif event.type == 'key' then local item = event.selected_item if event.id == 'enter' then -- We get here when there's no selectable item in menu and user presses enter. cleanup_and_close() elseif event.key == 'f5' and opts.on_reload and item then opts.on_reload({type = 'reload', index = item.index, value = item.value}) elseif event.key == 'del' and (opts.on_remove or opts.on_delete) and item then if itable_has({nil, 'ctrl'}, event.modifiers) then remove_or_delete(item.index, item.value, event.menu_id, event.modifiers) end elseif opts.on_key then opts.on_key(event --[[@as MenuEventKey]], cleanup_and_close) end elseif event.type == 'paste' and opts.on_paste then opts.on_paste(event --[[@as MenuEventPaste]]) elseif event.type == 'close' then cleanup_and_close() elseif event.type == 'move' and opts.on_move then opts.on_move(event --[[@as MenuEventMove]]) elseif event.type == 'remove' and opts.on_move then end end) mp.observe_property(opts.list_prop, 'native', handle_list_prop_change) if opts.active_prop then mp.observe_property(opts.active_prop, 'native', handle_active_prop_change) end end end ---@param opts {title: string; type: string; prop: string; enable_prop?: string; secondary?: {prop: string; icon: string; enable_prop?: string}; load_command: string; download_command?: string} function create_select_tracklist_type_menu_opener(opts) local snd = opts.secondary local function get_props() return tonumber(mp.get_property(opts.prop)), snd and tonumber(mp.get_property(snd.prop)) or nil end local function serialize_tracklist(tracklist) local items = {} if opts.load_command then items[#items + 1] = { title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}', actions = opts.download_command and {{name = 'download', icon = 'language', label = t('Search online')}} or nil, } end if #items > 0 then items[#items].separator = true end local track_prop_index, snd_prop_index = get_props() local filename = mp.get_property_native('filename/no-ext') local escaped_filename = filename and regexp_escape(filename) local first_item_index = #items + 1 local active_index = nil local disabled_item = nil local track_actions = nil local track_external_actions = {} if snd then local action = { name = 'as_secondary', icon = snd.icon, label = t('Use as secondary') .. ' (shift+enter/click)', } track_actions = {action} table.insert(track_external_actions, action) end table.insert(track_external_actions, {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'}) table.insert(track_external_actions, {name = 'remove', icon = 'delete', label = t('Remove') .. ' (del)'}) for _, track in ipairs(tracklist) do if track.type == opts.type then local hint_values = {} local track_selected = track.selected and track.id == track_prop_index local snd_selected = snd and track.id == snd_prop_index local function h(value) value = trim(value) if #value > 0 then hint_values[#hint_values + 1] = value end end if track.lang then h(track.lang) end if track['demux-h'] then h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p')) end if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end h(track.codec) if track['audio-channels'] then h(track['audio-channels'] == 1 and t('%s channel', track['audio-channels']) or t('%s channels', track['audio-channels'])) end if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end if track.forced then h(t('forced')) end if track.default then h(t('default')) end if track.external then local extension = track.title:match('%.([^%.]+)$') if track.title and escaped_filename and extension then track.title = trim(track.title:gsub(escaped_filename .. '%.?', ''):gsub('%.?([^%.]+)$', '')) if track.title == '' or track.lang and track.title:lower() == track.lang:lower() then track.title = nil end end h(t('external')) end items[#items + 1] = { title = (track.title and track.title or t('Track %s', track.id)), hint = table.concat(hint_values, ', '), value = track.id, active = track_selected or snd_selected, italic = snd_selected, icon = snd and snd_selected and snd.icon or nil, actions = track.external and track_external_actions or track_actions, } if track_selected then if disabled_item then disabled_item.active = false end active_index = #items end end end return items, active_index or first_item_index end local function reload(id) if id then mp.commandv(opts.type .. '-reload', id) end end local function remove(id) if id then mp.commandv(opts.type .. '-remove', id) end end ---@param event MenuEventActivate local function handle_activate(event) if event.value == '{load}' then mp.command(event.action == 'download' and opts.download_command or opts.load_command) else if snd and (event.action == 'as_secondary' or event.modifiers == 'shift') then local _, snd_track_index = get_props() mp.commandv('set', snd.prop, event.value == snd_track_index and 'no' or event.value) if snd.enable_prop then mp.commandv('set', snd.enable_prop, 'yes') end elseif event.action == 'reload' then reload(event.value) elseif event.action == 'remove' then remove(event.value) elseif not event.modifiers or event.modifiers == 'alt' then mp.commandv('set', opts.prop, event.value == get_props() and 'no' or event.value) if opts.enable_prop then mp.commandv('set', opts.enable_prop, 'yes') end end end end ---@param event MenuEventKey local function handle_key(event) if event.selected_item then if event.id == 'f5' then reload(event.selected_item.value) elseif event.id == 'del' then remove(event.selected_item.value) end end end return create_self_updating_menu_opener({ title = opts.title, footnote = t('Toggle to disable.') .. ' ' .. t('Paste path or url to add.'), type = opts.type, list_prop = 'track-list', serializer = serialize_tracklist, on_activate = handle_activate, on_key = handle_key, actions_place = 'outside', on_paste = function(event) load_track(opts.type, event.value) end, }) end ---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], file_actions?: MenuAction[], directory_actions?: MenuAction[], active_path?: string, selected_path?: string; on_close?: fun()} -- Opens a file navigation menu with items inside `directory_path`. ---@param directory_path string ---@param handle_activate fun(event: MenuEventActivate) ---@param opts NavigationMenuOptions function open_file_navigation_menu(directory_path, handle_activate, opts) if directory_path == '{drives}' then if state.platform ~= 'windows' then directory_path = '/' end else directory_path = normalize_path(mp.command_native({'expand-path', directory_path})) end opts = opts or {} ---@type string|nil local current_directory = nil ---@type Menu local menu ---@type string | nil local back_path local separator = path_separator(directory_path) ---@param path string Can be path to a directory, or special string `'{drives}'` to get windows drives items. ---@param selected_path? string Marks item with this path as active. ---@return MenuStackChild[] menu_items ---@return number selected_index ---@return string|nil error local function serialize_items(path, selected_path) if path == '{drives}' then local process = mp.command_native({ name = 'subprocess', capture_stdout = true, playback_only = false, args = {'fsutil', 'fsinfo', 'drives'}, }) local items, selected_index = {}, 1 if process.status == 0 then for drive in process.stdout:gmatch("(%a:)\\") do if drive then local drive_path = normalize_path(drive) items[#items + 1] = { title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path, } if selected_path == drive_path then selected_index = #items end end end else return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr) end return items, selected_index end local serialized = serialize_path(path) if not serialized then return {}, 0, 'Couldn\'t serialize path "' .. path .. '.' end local files, directories, error = read_directory(serialized.path, { types = opts.allowed_types, hidden = options.show_hidden_files, }) if error then return {}, 1, error end local is_root = not serialized.dirname if not files or not directories then return {}, 0 end sort_strings(directories) sort_strings(files) -- Pre-populate items with parent directory selector if not at root -- Each item value is a serialized path table it points to. local items = {} if is_root then if state.platform == 'windows' then items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true} end else items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true} end back_path = items[#items] and items[#items].value local selected_index = #items + 1 for _, dir in ipairs(directories) do items[#items + 1] = { title = dir .. ' ' .. separator, value = join_path(path, dir), bold = true, actions = opts .directory_actions, } end for _, file in ipairs(files) do items[#items + 1] = {title = file, value = join_path(path, file), actions = opts.file_actions} end for index, item in ipairs(items) do if not item.is_to_parent then if opts.active_path == item.value then item.active = true if not selected_path then selected_index = index end end if selected_path == item.value then selected_index = index end end end return items, selected_index end local menu_data = { type = opts.type, title = opts.title or '', footnote = t('%s to go up in tree.', 'alt+up') .. ' ' .. t('Paste path or url to open.'), items = {}, on_paste = 'callback', } ---@param path string local function open_directory(path) local items, selected_index, error = serialize_items(path, current_directory) if error then msg.error(error) items = {{title = 'Something went wrong. See console for errors.', selectable = false, muted = true}} end local title = opts.title if not title then if path == '{drives}' then title = 'Drives' else local serialized = serialize_path(path) title = serialized and serialized.basename .. separator or '??' end end current_directory = path menu_data.title = title menu_data.items = items menu:search_cancel() menu:update(menu_data) menu:select_index(selected_index) menu:scroll_to_index(selected_index, nil, true) end local function close() menu:close() if opts.on_close then opts.on_close() end end ---@param event MenuEventActivate ---@param only_if_dir? boolean Activate item only if it's a directory. local function activate(event, only_if_dir) local path = event.value local is_drives = path == '{drives}' if is_drives then open_directory(path) return end local info, error = utils.file_info(path) if not info then msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or '')) return end if info.is_dir and not event.modifiers and not event.action then open_directory(path) elseif not only_if_dir then handle_activate(event) end end menu = Menu:open(menu_data, function(event) if event.type == 'activate' then activate(event --[[@as MenuEventActivate]]) elseif event.type == 'back' or event.type == 'key' and itable_has({'alt+up', 'left'}, event.id) then if back_path then open_directory(back_path) end elseif event.type == 'paste' then handle_activate({type = 'activate', value = event.value}) elseif event.type == 'key' then if event.id == 'right' then local selected_item = event.selected_item if selected_item then activate(table_assign({}, selected_item, {type = 'activate'}), true) end elseif event.id == 'ctrl+c' and event.selected_item then set_clipboard(event.selected_item.value) end elseif event.type == 'close' then close() end end) open_directory(directory_path) return menu end -- On demand menu items loading do ---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil local all_user_bindings = nil ---@type MenuStackItem[]|nil local menu_items = nil local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end -- Returns all relevant bindings from `input.conf`, even if they are overwritten -- (same key bound to something else later) or have no keys (uosc menu items). function get_all_user_bindings() if all_user_bindings then return all_user_bindings end all_user_bindings = {} local input_conf_property = mp.get_property_native('input-conf') local input_conf_iterator if input_conf_property:sub(1, 9) == 'memory://' then -- mpv.net v7 local input_conf_lines = split(input_conf_property:sub(10), '\n') local i = 0 input_conf_iterator = function() i = i + 1 return input_conf_lines[i] end else local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property local input_conf_path = mp.command_native({'expand-path', input_conf}) local input_conf_meta, meta_error = utils.file_info(input_conf_path) -- File doesn't exist if not input_conf_meta or not input_conf_meta.is_file then menu_items = create_default_menu_items() return menu_items, all_user_bindings end input_conf_iterator = io.lines(input_conf_path) end for line in input_conf_iterator do local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$') local is_commented_out = key and key:sub(1, 1) == '#' if comment and #comment > 0 then comment = comment:sub(2) end if command then command = trim(command) end local is_menu_item = comment and is_uosc_menu_comment(comment) if key -- Filter out stuff like `#F2`, which is clearly intended to be disabled and not (is_commented_out and #key > 1) -- Filter out comments that are not uosc menu items and (not is_commented_out or is_menu_item) then all_user_bindings[#all_user_bindings + 1] = { key = key, cmd = command, comment = comment or '', is_menu_item = is_menu_item, } end end return all_user_bindings end function get_menu_items() if menu_items then return menu_items end local all_user_bindings = get_all_user_bindings() local main_menu = {items = {}, items_by_command = {}} local by_id = {} for _, bind in ipairs(all_user_bindings) do local key, command, comment = bind.key, bind.cmd, bind.comment local title = '' if comment then local comments = split(comment, '#') local titles = itable_filter(comments, is_uosc_menu_comment) if titles and #titles > 0 then title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*') end end if title ~= '' then local is_dummy = key:sub(1, 1) == '#' local submenu_id = '' local target_menu = main_menu local title_parts = split(title or '', ' *> *') for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do if index < #title_parts then submenu_id = submenu_id .. title_part if not by_id[submenu_id] then local items = {} by_id[submenu_id] = {items = items, items_by_command = {}} target_menu.items[#target_menu.items + 1] = {title = title_part, items = items} end target_menu = by_id[submenu_id] else -- If command is already in menu, just append the key to it if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then local hint = target_menu.items_by_command[command].hint target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key else -- Separator if title_part:sub(1, 3) == '---' then local last_item = target_menu.items[#target_menu.items] if last_item then last_item.separator = true end elseif command ~= 'ignore' then local item = { title = title_part, hint = not is_dummy and key or nil, value = command, } if command == '' then item.selectable = false item.muted = true item.italic = true else target_menu.items_by_command[command] = item end target_menu.items[#target_menu.items + 1] = item end end end end end end menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items() return menu_items end end -- Adapted from `stats.lua` function get_keybinds_items() local items = {} -- uosc and mpv-menu-plugin binds with no keys local no_key_menu_binds = itable_filter( get_all_user_bindings(), function(b) return b.is_menu_item and b.cmd and b.cmd ~= '' and (b.key == '#' or b.key == '_') end ) local binds_dump = itable_join(find_active_keybindings(), no_key_menu_binds) local ids = {} -- Convert to menu items for _, bind in pairs(binds_dump) do local id = bind.key .. '<>' .. bind.cmd if not ids[id] then ids[id] = true items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd} end end -- Sort table.sort(items, function(a, b) return a.title < b.title end) return #items > 0 and items or { { title = t('%s are empty', '`input-bindings`'), selectable = false, align = 'center', italic = true, muted = true, }, } end function open_stream_quality_menu() if Menu:is_open('stream-quality') then Menu:close() return end local ytdl_format = mp.get_property_native('ytdl-format') local items = {} ---@type Menu local menu for _, height in ipairs(config.stream_quality_options) do local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']' items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format} end menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event) if event.type == 'activate' then mp.set_property('ytdl-format', event.value) -- Reload the video to apply new format -- This is taken from https://github.com/jgreco/mpv-youtube-quality -- which is in turn taken from https://github.com/4e6/mpv-reload/ local duration = mp.get_property_native('duration') local time_pos = mp.get_property('time-pos') mp.command('playlist-play-index current') -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero -- duration property. When reloading VOD, to keep the current time position -- we should provide offset from the start. Stream doesn't have fixed start. -- Decent choice would be to reload stream from it's current 'live' position. -- That's the reason we don't pass the offset when reloading streams. if duration and duration > 0 then local function seeker() mp.commandv('seek', time_pos, 'absolute') mp.unregister_event(seeker) end mp.register_event('file-loaded', seeker) end if not event.alt then menu:close() end end end) end function open_open_file_menu() if Menu:is_open('open-file') then Menu:close() return end ---@type Menu | nil local menu local directory local active_file if state.path == nil or is_protocol(state.path) then directory = options.default_directory active_file = nil else local serialized = serialize_path(state.path) if serialized then directory = serialized.dirname active_file = serialized.path end end if not directory then msg.error('Couldn\'t serialize path "' .. state.path .. '".') return end -- Update active file in directory navigation menu local function handle_file_loaded() if menu and menu:is_alive() then menu:activate_one_value(normalize_path(mp.get_property_native('path'))) end end menu = open_file_navigation_menu( directory, function(event) if not menu then return end local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile' if event.modifiers == 'shift' or event.action == 'add_to_playlist' then mp.commandv(command, event.value, 'append') local serialized = serialize_path(event.value) local filename = serialized and serialized.basename or event.value mp.commandv('show-text', t('Added to playlist') .. ': ' .. filename, 3000) elseif itable_has({nil, 'ctrl', 'alt', 'alt+ctrl'}, event.modifiers) and itable_has({nil, 'force_open'}, event.action) then mp.commandv(command, event.value) if not event.alt then menu:close() end end end, { type = 'open-file', allowed_types = config.types.media, active_path = active_file, directory_actions = { {name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'}, {name = 'force_open', icon = 'play_circle_outline', label = t('Open in mpv') .. ' (ctrl+enter/click)'}, }, file_actions = { {name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'}, }, keep_open = true, on_close = function() mp.unregister_event(handle_file_loaded) end, } ) if menu then mp.register_event('file-loaded', handle_file_loaded) end end ---@param opts {prop: 'sub'|'audio'|'video'; title: string; loaded_message: string; allowed_types: string[]} function create_track_loader_menu_opener(opts) local menu_type = 'load-' .. opts.prop return function() if Menu:is_open(menu_type) then Menu:close() return end ---@type Menu local menu local path = state.path if path then if is_protocol(path) then path = false else local serialized_path = serialize_path(path) path = serialized_path ~= nil and serialized_path.dirname or false end end if not path then path = options.default_directory end local function handle_activate(event) load_track(opts.prop, event.value) local serialized = serialize_path(event.value) local filename = serialized and serialized.basename or event.value mp.commandv('show-text', opts.loaded_message .. ': ' .. filename, 3000) if not event.alt then menu:close() end end menu = open_file_navigation_menu(path, handle_activate, { type = menu_type, title = opts.title, allowed_types = opts.allowed_types, }) end end function open_subtitle_downloader() local menu_type = 'download-subtitles' ---@type Menu local menu if Menu:is_open(menu_type) then Menu:close() return end local search_suggestion, file_path, destination_directory = '', nil, nil local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent} if state.path then if is_protocol(state.path) then if not is_protocol(state.title) then search_suggestion = state.title end else local serialized_path = serialize_path(state.path) if serialized_path then search_suggestion = serialized_path.filename file_path = state.path destination_directory = serialized_path.dirname end end end local force_destination = options.subtitles_directory:sub(1, 1) == '!' if force_destination or not destination_directory then local subtitles_directory = options.subtitles_directory:sub(force_destination and 2 or 1) destination_directory = mp.command_native({'expand-path', subtitles_directory}) end local handle_download, handle_search -- Checks if there an error, or data is invalid. If true, reports the error, -- updates menu to inform about it, and returns true. ---@param error string|nil ---@param data any ---@param check_is_valid? fun(data: any):boolean ---@return boolean abort Whether the further response handling should be aborted. local function should_abort(error, data, check_is_valid) if error or not data or (not check_is_valid or not check_is_valid(data)) then menu:update_items({ { title = t('Something went wrong.'), align = 'center', muted = true, italic = true, selectable = false, }, { title = t('See console for details.'), align = 'center', muted = true, italic = true, selectable = false, }, }) msg.error(error or ('Invalid response: ' .. (utils.format_json(data) or tostring(data)))) return true end return false end ---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number} handle_download = function(data) if data.kind == 'page' then handle_search(data.query, data.page) return end menu = Menu:open({ type = menu_type .. '-result', search_style = 'disabled', items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}}, }, function(event) if event.type == 'key' and event.key == 'enter' then menu:close() end end) local args = itable_join({'download-subtitles'}, credentials, { '--file-id', tostring(data.id), '--destination', destination_directory, }) call_ziggy_async(args, function(error, data) if not menu:is_alive() then return end if should_abort(error, data, function(data) return type(data.file) == 'string' end) then return end load_track('sub', data.file) menu:update_items({ { title = t('Subtitles loaded & enabled'), bold = true, icon = 'check', selectable = false, }, { title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total), italic = true, muted = true, icon = 'file_download', selectable = false, }, { title = t('Resets in: %s', data.reset_time), italic = true, muted = true, icon = 'schedule', selectable = false, }, }) end) end ---@param query string ---@param page number|nil handle_search = function(query, page) if not menu:is_alive() then return end page = math.max(1, type(page) == 'number' and round(page) or 1) menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}}) local args = itable_join({'search-subtitles'}, credentials) local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end) args[#args + 1] = '--languages' args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en` args[#args + 1] = '--page' args[#args + 1] = tostring(page) if file_path then args[#args + 1] = '--hash' args[#args + 1] = file_path end if query and #query > 0 then args[#args + 1] = '--query' args[#args + 1] = query end call_ziggy_async(args, function(error, data) if not menu:is_alive() then return end local function check_is_valid(data) return type(data.data) == 'table' and data.page and data.total_pages end if should_abort(error, data, check_is_valid) then return end local subs = itable_filter(data.data, function(sub) return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and #sub.attributes.files > 0 end) local items = itable_map(subs, function(sub) local hints = {sub.attributes.language} if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end local url = sub.attributes.url return { title = sub.attributes.release, hint = table.concat(hints, ', '), value = {kind = 'file', id = sub.attributes.files[1].file_id, url = url}, keep_open = true, actions = url and {{name = 'open_in_browser', icon = 'open_in_new', label = t('Open in browser') .. ' (shift)'}}, } end) if #items == 0 then items = { {title = t('no results'), align = 'center', muted = true, italic = true, selectable = false}, } end if data.page > 1 then items[#items + 1] = { title = t('Previous page'), align = 'center', bold = true, italic = true, icon = 'navigate_before', keep_open = true, value = {kind = 'page', query = query, page = data.page - 1}, } end if data.page < data.total_pages then items[#items + 1] = { title = t('Next page'), align = 'center', bold = true, italic = true, icon = 'navigate_next', keep_open = true, value = {kind = 'page', query = query, page = data.page + 1}, } end menu:update_items(items) end) end local initial_items = { {title = t('%s to search', 'enter'), align = 'center', muted = true, italic = true, selectable = false}, } menu = Menu:open( { type = menu_type, title = t('enter query'), items = initial_items, search_style = 'palette', on_search = 'callback', search_debounce = 'submit', search_suggestion = search_suggestion, }, function(event) if event.type == 'activate' then if event.action == 'open_in_browser' or event.modifiers == 'shift' then local command = ({ windows = 'explorer', linux = 'xdg-open', darwin = 'open', })[state.platform] local url = event.value.url mp.command_native_async({ name = 'subprocess', capture_stderr = true, capture_stdout = true, playback_only = false, args = {command, url}, }, function(success, result, error) if not success then local err_str = utils.to_string(error or result.stderr) msg.error('Error trying to open url "' .. url .. '" in browser: ' .. err_str) end end) elseif not event.action then handle_download(event.value) end elseif event.type == 'search' then handle_search(event.query) end end ) end