1136 lines
36 KiB
Lua
1136 lines
36 KiB
Lua
---@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
|