menus.lua 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. ---@param data MenuData
  2. ---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
  3. function open_command_menu(data, opts)
  4. local function run_command(command)
  5. if type(command) == 'string' then
  6. mp.command(command)
  7. else
  8. ---@diagnostic disable-next-line: deprecated
  9. mp.commandv(unpack(command))
  10. end
  11. end
  12. ---@type MenuOptions
  13. local menu_opts = {}
  14. if opts then
  15. menu_opts.mouse_nav = opts.mouse_nav
  16. if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end
  17. end
  18. local menu = Menu:open(data, run_command, menu_opts)
  19. if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
  20. return menu
  21. end
  22. ---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
  23. function toggle_menu_with_items(opts)
  24. if Menu:is_open('menu') then
  25. Menu:close()
  26. else
  27. open_command_menu({type = 'menu', items = get_menu_items(), search_submenus = true}, opts)
  28. end
  29. end
  30. ---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
  31. function create_self_updating_menu_opener(opts)
  32. return function()
  33. if Menu:is_open(opts.type) then
  34. Menu:close()
  35. return
  36. end
  37. local list = mp.get_property_native(opts.list_prop)
  38. local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
  39. local menu
  40. local function update() menu:update_items(opts.serializer(list, active)) end
  41. local ignore_initial_list = true
  42. local function handle_list_prop_change(name, value)
  43. if ignore_initial_list then
  44. ignore_initial_list = false
  45. else
  46. list = value
  47. update()
  48. end
  49. end
  50. local ignore_initial_active = true
  51. local function handle_active_prop_change(name, value)
  52. if ignore_initial_active then
  53. ignore_initial_active = false
  54. else
  55. active = value
  56. update()
  57. end
  58. end
  59. local initial_items, selected_index = opts.serializer(list, active)
  60. -- Items and active_index are set in the handle_prop_change callback, since adding
  61. -- a property observer triggers its handler immediately, we just let that initialize the items.
  62. menu = Menu:open(
  63. {
  64. type = opts.type,
  65. title = opts.title,
  66. items = initial_items,
  67. selected_index = selected_index,
  68. on_paste = opts.on_paste,
  69. },
  70. opts.on_select, {
  71. on_open = function()
  72. mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
  73. if opts.active_prop then
  74. mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
  75. end
  76. end,
  77. on_close = function()
  78. mp.unobserve_property(handle_list_prop_change)
  79. mp.unobserve_property(handle_active_prop_change)
  80. end,
  81. on_move_item = opts.on_move_item,
  82. on_delete_item = opts.on_delete_item,
  83. })
  84. end
  85. end
  86. function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command, download_command)
  87. local function serialize_tracklist(tracklist)
  88. local items = {}
  89. if download_command then
  90. items[#items + 1] = {
  91. title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
  92. }
  93. end
  94. if load_command then
  95. items[#items + 1] = {
  96. title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}',
  97. }
  98. end
  99. if #items > 0 then
  100. items[#items].separator = true
  101. end
  102. local first_item_index = #items + 1
  103. local active_index = nil
  104. local disabled_item = nil
  105. -- Add option to disable a subtitle track. This works for all tracks,
  106. -- but why would anyone want to disable audio or video? Better to not
  107. -- let people mistakenly select what is unwanted 99.999% of the time.
  108. -- If I'm mistaken and there is an active need for this, feel free to
  109. -- open an issue.
  110. if track_type == 'sub' then
  111. disabled_item = {title = t('Disabled'), italic = true, muted = true, hint = '—', value = nil, active = true}
  112. items[#items + 1] = disabled_item
  113. end
  114. for _, track in ipairs(tracklist) do
  115. if track.type == track_type then
  116. local hint_values = {}
  117. local function h(value) hint_values[#hint_values + 1] = value end
  118. if track.lang then h(track.lang:upper()) end
  119. if track['demux-h'] then
  120. h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
  121. end
  122. if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
  123. h(track.codec)
  124. if track['audio-channels'] then
  125. h(track['audio-channels'] == 1
  126. and t('%s channel', track['audio-channels'])
  127. or t('%s channels', track['audio-channels']))
  128. end
  129. if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
  130. if track.forced then h(t('forced')) end
  131. if track.default then h(t('default')) end
  132. if track.external then h(t('external')) end
  133. items[#items + 1] = {
  134. title = (track.title and track.title or t('Track %s', track.id)),
  135. hint = table.concat(hint_values, ', '),
  136. value = track.id,
  137. active = track.selected,
  138. }
  139. if track.selected then
  140. if disabled_item then disabled_item.active = false end
  141. active_index = #items
  142. end
  143. end
  144. end
  145. return items, active_index or first_item_index
  146. end
  147. local function handle_select(value)
  148. if value == '{download}' then
  149. mp.command(download_command)
  150. elseif value == '{load}' then
  151. mp.command(load_command)
  152. else
  153. mp.commandv('set', track_prop, value and value or 'no')
  154. -- If subtitle track was selected, assume the user also wants to see it
  155. if value and track_type == 'sub' then
  156. mp.commandv('set', 'sub-visibility', 'yes')
  157. end
  158. end
  159. end
  160. return create_self_updating_menu_opener({
  161. title = menu_title,
  162. type = track_type,
  163. list_prop = 'track-list',
  164. serializer = serialize_tracklist,
  165. on_select = handle_select,
  166. on_paste = function(path) load_track(track_type, path) end,
  167. })
  168. end
  169. ---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
  170. -- Opens a file navigation menu with items inside `directory_path`.
  171. ---@param directory_path string
  172. ---@param handle_select fun(path: string, mods: Modifiers): nil
  173. ---@param opts NavigationMenuOptions
  174. function open_file_navigation_menu(directory_path, handle_select, opts)
  175. directory = serialize_path(normalize_path(directory_path))
  176. opts = opts or {}
  177. if not directory then
  178. msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
  179. return
  180. end
  181. local files, directories = read_directory(directory.path, {
  182. types = opts.allowed_types,
  183. hidden = options.show_hidden_files,
  184. })
  185. local is_root = not directory.dirname
  186. local path_separator = path_separator(directory.path)
  187. if not files or not directories then return end
  188. sort_strings(directories)
  189. sort_strings(files)
  190. -- Pre-populate items with parent directory selector if not at root
  191. -- Each item value is a serialized path table it points to.
  192. local items = {}
  193. if is_root then
  194. if state.platform == 'windows' then
  195. items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true}
  196. end
  197. else
  198. items[#items + 1] = {title = '..', hint = t('parent dir'), value = directory.dirname, separator = true}
  199. end
  200. local back_path = items[#items] and items[#items].value
  201. local selected_index = #items + 1
  202. for _, dir in ipairs(directories) do
  203. items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
  204. end
  205. for _, file in ipairs(files) do
  206. items[#items + 1] = {title = file, value = join_path(directory.path, file)}
  207. end
  208. for index, item in ipairs(items) do
  209. if not item.value.is_to_parent and opts.active_path == item.value then
  210. item.active = true
  211. if not opts.selected_path then selected_index = index end
  212. end
  213. if opts.selected_path == item.value then selected_index = index end
  214. end
  215. ---@type MenuCallback
  216. local function open_path(path, meta)
  217. local is_drives = path == '{drives}'
  218. local is_to_parent = is_drives or #path < #directory_path
  219. local inheritable_options = {
  220. type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
  221. keep_open = opts.keep_open,
  222. }
  223. if is_drives then
  224. open_drives_menu(function(drive_path)
  225. open_file_navigation_menu(drive_path, handle_select, inheritable_options)
  226. end, {
  227. type = inheritable_options.type,
  228. title = inheritable_options.title,
  229. selected_path = directory.path,
  230. on_open = opts.on_open,
  231. on_close = opts.on_close,
  232. })
  233. return
  234. end
  235. local info, error = utils.file_info(path)
  236. if not info then
  237. msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
  238. return
  239. end
  240. if info.is_dir and not meta.modifiers.alt and not meta.modifiers.ctrl then
  241. -- Preselect directory we are coming from
  242. if is_to_parent then
  243. inheritable_options.selected_path = directory.path
  244. end
  245. open_file_navigation_menu(path, handle_select, inheritable_options)
  246. else
  247. handle_select(path, meta.modifiers)
  248. end
  249. end
  250. local function handle_back()
  251. if back_path then open_path(back_path, {modifiers = {}}) end
  252. end
  253. local menu_data = {
  254. type = opts.type,
  255. title = opts.title or directory.basename .. path_separator,
  256. items = items,
  257. keep_open = opts.keep_open,
  258. selected_index = selected_index,
  259. }
  260. local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
  261. return Menu:open(menu_data, open_path, menu_options)
  262. end
  263. -- Opens a file navigation menu with Windows drives as items.
  264. ---@param handle_select fun(path: string): nil
  265. ---@param opts? NavigationMenuOptions
  266. function open_drives_menu(handle_select, opts)
  267. opts = opts or {}
  268. local process = mp.command_native({
  269. name = 'subprocess',
  270. capture_stdout = true,
  271. playback_only = false,
  272. args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
  273. })
  274. local items, selected_index = {}, 1
  275. if process.status == 0 then
  276. for _, value in ipairs(split(process.stdout, '\n')) do
  277. local drive = string.match(value, 'Name=([A-Z]:)')
  278. if drive then
  279. local drive_path = normalize_path(drive)
  280. items[#items + 1] = {
  281. title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
  282. }
  283. if opts.selected_path == drive_path then selected_index = #items end
  284. end
  285. end
  286. else
  287. msg.error(process.stderr)
  288. end
  289. return Menu:open(
  290. {type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index},
  291. handle_select
  292. )
  293. end
  294. -- On demand menu items loading
  295. do
  296. local items = nil
  297. function get_menu_items()
  298. if items then return items end
  299. local input_conf_property = mp.get_property_native('input-conf')
  300. local input_conf_iterator
  301. if input_conf_property:sub(1, 9) == 'memory://' then
  302. -- mpv.net v7
  303. local input_conf_lines = split(input_conf_property:sub(10), '\n')
  304. local i = 0
  305. input_conf_iterator = function()
  306. i = i + 1
  307. return input_conf_lines[i]
  308. end
  309. else
  310. local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property
  311. local input_conf_path = mp.command_native({'expand-path', input_conf})
  312. local input_conf_meta, meta_error = utils.file_info(input_conf_path)
  313. -- File doesn't exist
  314. if not input_conf_meta or not input_conf_meta.is_file then
  315. items = create_default_menu_items()
  316. return items
  317. end
  318. input_conf_iterator = io.lines(input_conf_path)
  319. end
  320. local main_menu = {items = {}, items_by_command = {}}
  321. local by_id = {}
  322. for line in input_conf_iterator do
  323. local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
  324. local title = ''
  325. if comment then
  326. local comments = split(comment, '#')
  327. local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end)
  328. if titles and #titles > 0 then
  329. title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
  330. end
  331. end
  332. if title ~= '' then
  333. local is_dummy = key:sub(1, 1) == '#'
  334. local submenu_id = ''
  335. local target_menu = main_menu
  336. local title_parts = split(title or '', ' *> *')
  337. for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
  338. if index < #title_parts then
  339. submenu_id = submenu_id .. title_part
  340. if not by_id[submenu_id] then
  341. local items = {}
  342. by_id[submenu_id] = {items = items, items_by_command = {}}
  343. target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
  344. end
  345. target_menu = by_id[submenu_id]
  346. else
  347. if command == 'ignore' then break end
  348. -- If command is already in menu, just append the key to it
  349. if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
  350. local hint = target_menu.items_by_command[command].hint
  351. target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
  352. else
  353. -- Separator
  354. if title_part:sub(1, 3) == '---' then
  355. local last_item = target_menu.items[#target_menu.items]
  356. if last_item then last_item.separator = true end
  357. else
  358. local item = {
  359. title = title_part,
  360. hint = not is_dummy and key or nil,
  361. value = command,
  362. }
  363. if command == '' then
  364. item.selectable = false
  365. item.muted = true
  366. item.italic = true
  367. else
  368. target_menu.items_by_command[command] = item
  369. end
  370. target_menu.items[#target_menu.items + 1] = item
  371. end
  372. end
  373. end
  374. end
  375. end
  376. end
  377. items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
  378. return items
  379. end
  380. end
  381. -- Adapted from `stats.lua`
  382. function get_keybinds_items()
  383. local items = {}
  384. local active = find_active_keybindings()
  385. -- Convert to menu items
  386. for _, bind in pairs(active) do
  387. items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
  388. end
  389. -- Sort
  390. table.sort(items, function(a, b) return a.title < b.title end)
  391. return #items > 0 and items or {
  392. {
  393. title = t('%s are empty', '`input-bindings`'),
  394. selectable = false,
  395. align = 'center',
  396. italic = true,
  397. muted = true,
  398. },
  399. }
  400. end
  401. function open_stream_quality_menu()
  402. if Menu:is_open('stream-quality') then
  403. Menu:close()
  404. return
  405. end
  406. local ytdl_format = mp.get_property_native('ytdl-format')
  407. local items = {}
  408. for _, height in ipairs(config.stream_quality_options) do
  409. local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
  410. items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
  411. end
  412. Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
  413. mp.set_property('ytdl-format', format)
  414. -- Reload the video to apply new format
  415. -- This is taken from https://github.com/jgreco/mpv-youtube-quality
  416. -- which is in turn taken from https://github.com/4e6/mpv-reload/
  417. local duration = mp.get_property_native('duration')
  418. local time_pos = mp.get_property('time-pos')
  419. mp.command('playlist-play-index current')
  420. -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
  421. -- duration property. When reloading VOD, to keep the current time position
  422. -- we should provide offset from the start. Stream doesn't have fixed start.
  423. -- Decent choice would be to reload stream from it's current 'live' position.
  424. -- That's the reason we don't pass the offset when reloading streams.
  425. if duration and duration > 0 then
  426. local function seeker()
  427. mp.commandv('seek', time_pos, 'absolute')
  428. mp.unregister_event(seeker)
  429. end
  430. mp.register_event('file-loaded', seeker)
  431. end
  432. end)
  433. end
  434. function open_open_file_menu()
  435. if Menu:is_open('open-file') then
  436. Menu:close()
  437. return
  438. end
  439. local directory
  440. local active_file
  441. if state.path == nil or is_protocol(state.path) then
  442. local serialized = serialize_path(get_default_directory())
  443. if serialized then
  444. directory = serialized.path
  445. active_file = nil
  446. end
  447. else
  448. local serialized = serialize_path(state.path)
  449. if serialized then
  450. directory = serialized.dirname
  451. active_file = serialized.path
  452. end
  453. end
  454. if not directory then
  455. msg.error('Couldn\'t serialize path "' .. state.path .. '".')
  456. return
  457. end
  458. -- Update active file in directory navigation menu
  459. local menu = nil
  460. local function handle_file_loaded()
  461. if menu and menu:is_alive() then
  462. menu:activate_one_value(normalize_path(mp.get_property_native('path')))
  463. end
  464. end
  465. menu = open_file_navigation_menu(
  466. directory,
  467. function(path, mods)
  468. if mods.ctrl then
  469. mp.commandv('loadfile', path, 'append')
  470. else
  471. mp.commandv('loadfile', path)
  472. Menu:close()
  473. end
  474. end,
  475. {
  476. type = 'open-file',
  477. allowed_types = config.types.media,
  478. active_path = active_file,
  479. keep_open = true,
  480. on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
  481. on_close = function() mp.unregister_event(handle_file_loaded) end,
  482. }
  483. )
  484. end
  485. ---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
  486. function create_track_loader_menu_opener(opts)
  487. local menu_type = 'load-' .. opts.name
  488. local title = ({
  489. subtitles = t('Load subtitles'),
  490. audio = t('Load audio'),
  491. video = t('Load video'),
  492. })[opts.name]
  493. return function()
  494. if Menu:is_open(menu_type) then
  495. Menu:close()
  496. return
  497. end
  498. local path = state.path
  499. if path then
  500. if is_protocol(path) then
  501. path = false
  502. else
  503. local serialized_path = serialize_path(path)
  504. path = serialized_path ~= nil and serialized_path.dirname or false
  505. end
  506. end
  507. if not path then
  508. path = get_default_directory()
  509. end
  510. local function handle_select(path) load_track(opts.prop, path) end
  511. open_file_navigation_menu(path, handle_select, {
  512. type = menu_type, title = title, allowed_types = opts.allowed_types,
  513. })
  514. end
  515. end
  516. function open_subtitle_downloader()
  517. local menu_type = 'download-subtitles'
  518. ---@type Menu
  519. local menu
  520. if Menu:is_open(menu_type) then
  521. Menu:close()
  522. return
  523. end
  524. local search_suggestion, file_path = '', nil
  525. local destination_directory = mp.command_native({'expand-path', '~~/subtitles'})
  526. local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
  527. if state.path then
  528. if is_protocol(state.path) then
  529. if not is_protocol(state.title) then search_suggestion = state.title end
  530. else
  531. local serialized_path = serialize_path(state.path)
  532. if serialized_path then
  533. search_suggestion = serialized_path.filename
  534. file_path = state.path
  535. destination_directory = serialized_path.dirname
  536. end
  537. end
  538. end
  539. local handle_select, handle_search
  540. -- Ensures response is valid, and returns its payload, or handles error reporting,
  541. -- and returns `nil`, indicating the consumer should abort response handling.
  542. local function ensure_response_data(success, result, error, check)
  543. local data
  544. if success and result and result.status == 0 then
  545. data = utils.parse_json(result.stdout)
  546. if not data or not check(data) then
  547. data = (data and data.error == true) and data or {
  548. error = true,
  549. message = t('invalid response json (see console for details)'),
  550. message_verbose = 'invalid response json: ' .. utils.to_string(result.stdout),
  551. }
  552. end
  553. else
  554. data = {
  555. error = true,
  556. message = error or t('process exited with code %s (see console for details)', result.status),
  557. message_verbose = result.stdout .. result.stderr,
  558. }
  559. end
  560. if data.error then
  561. local message, message_verbose = data.message or t('unknown error'), data.message_verbose or data.message
  562. if message_verbose then msg.error(message_verbose) end
  563. menu:update_items({
  564. {
  565. title = message,
  566. hint = t('error'),
  567. muted = true,
  568. italic = true,
  569. selectable = false,
  570. },
  571. })
  572. return
  573. end
  574. return data
  575. end
  576. ---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number}
  577. handle_select = function(data)
  578. if data.kind == 'page' then
  579. handle_search(data.query, data.page)
  580. return
  581. end
  582. menu = Menu:open({
  583. type = menu_type .. '-result',
  584. search_style = 'disabled',
  585. items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
  586. }, function() end)
  587. local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
  588. '--file-id', tostring(data.id),
  589. '--destination', destination_directory,
  590. })
  591. mp.command_native_async({
  592. name = 'subprocess',
  593. capture_stderr = true,
  594. capture_stdout = true,
  595. playback_only = false,
  596. args = args,
  597. }, function(success, result, error)
  598. if not menu:is_alive() then return end
  599. local data = ensure_response_data(success, result, error, function(data)
  600. return type(data.file) == 'string'
  601. end)
  602. if not data then return end
  603. load_track('sub', data.file)
  604. menu:update_items({
  605. {
  606. title = t('Subtitles loaded & enabled'),
  607. bold = true,
  608. icon = 'check',
  609. selectable = false,
  610. },
  611. {
  612. title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
  613. italic = true,
  614. muted = true,
  615. icon = 'file_download',
  616. selectable = false,
  617. },
  618. {
  619. title = t('Resets in: %s', data.reset_time),
  620. italic = true,
  621. muted = true,
  622. icon = 'schedule',
  623. selectable = false,
  624. },
  625. })
  626. end)
  627. end
  628. ---@param query string
  629. ---@param page number|nil
  630. handle_search = function(query, page)
  631. if not menu:is_alive() then return end
  632. page = math.max(1, type(page) == 'number' and round(page) or 1)
  633. menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
  634. local args = itable_join({config.ziggy_path, 'search-subtitles'}, credentials)
  635. local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
  636. args[#args + 1] = '--languages'
  637. args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
  638. args[#args + 1] = '--page'
  639. args[#args + 1] = tostring(page)
  640. if file_path then
  641. args[#args + 1] = '--hash'
  642. args[#args + 1] = file_path
  643. end
  644. if query and #query > 0 then
  645. args[#args + 1] = '--query'
  646. args[#args + 1] = query
  647. end
  648. mp.command_native_async({
  649. name = 'subprocess',
  650. capture_stderr = true,
  651. capture_stdout = true,
  652. playback_only = false,
  653. args = args,
  654. }, function(success, result, error)
  655. if not menu:is_alive() then return end
  656. local data = ensure_response_data(success, result, error, function(data)
  657. return type(data.data) == 'table' and data.page and data.total_pages
  658. end)
  659. if not data then return end
  660. local subs = itable_filter(data.data, function(sub)
  661. return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and
  662. #sub.attributes.files > 0
  663. end)
  664. local items = itable_map(subs, function(sub)
  665. local hints = {sub.attributes.language}
  666. if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end
  667. if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end
  668. return {
  669. title = sub.attributes.release,
  670. hint = table.concat(hints, ', '),
  671. value = {kind = 'file', id = sub.attributes.files[1].file_id},
  672. keep_open = true,
  673. }
  674. end)
  675. if #items == 0 then
  676. items = {
  677. {title = t('no results'), align = 'center', muted = true, italic = true, selectable = false},
  678. }
  679. end
  680. if data.page > 1 then
  681. items[#items + 1] = {
  682. title = t('Previous page'),
  683. align = 'center',
  684. bold = true,
  685. italic = true,
  686. icon = 'navigate_before',
  687. keep_open = true,
  688. value = {kind = 'page', query = query, page = data.page - 1},
  689. }
  690. end
  691. if data.page < data.total_pages then
  692. items[#items + 1] = {
  693. title = t('Next page'),
  694. align = 'center',
  695. bold = true,
  696. italic = true,
  697. icon = 'navigate_next',
  698. keep_open = true,
  699. value = {kind = 'page', query = query, page = data.page + 1},
  700. }
  701. end
  702. menu:update_items(items)
  703. end)
  704. end
  705. local initial_items = {
  706. {title = t('%s to search', 'ctrl+enter'), align = 'center', muted = true, italic = true, selectable = false},
  707. }
  708. menu = Menu:open(
  709. {
  710. type = menu_type,
  711. title = t('enter query'),
  712. items = initial_items,
  713. search_style = 'palette',
  714. on_search = handle_search,
  715. search_debounce = 'submit',
  716. search_suggestion = search_suggestion,
  717. },
  718. handle_select
  719. )
  720. end