feat: menu lifecycle and actions API (#936)

This commit is contained in:
Tomas Klaen
2024-08-28 17:37:53 +02:00
committed by GitHub
parent a0a608d451
commit 7f054a45f8
7 changed files with 909 additions and 859 deletions

289
README.md
View File

@@ -27,8 +27,9 @@ Features:
- Speed bar: change speed by `speed_step` per scroll.
- Just hovering video with no UI widget below cursor: your configured wheel bindings from `input.conf`.
- Right click on volume or speed elements to reset them.
- Transform chapters into timeline ranges (the red portion of the timeline in the preview).
- And a lot of useful options and commands to bind keys to.
- Transforming chapters into timeline ranges (the red portion of the timeline in the preview).
- A lot of useful options and commands to bind keys to.
- [API for 3rd party scripts](https://github.com/tomasklaen/uosc/wiki) to extend, or use uosc to render their menus.
[Changelog](https://github.com/tomasklaen/uosc/releases).
@@ -115,18 +116,18 @@ To change the font, **uosc** respects the mpv's `osd-font` configuration.
These bindings are active when any **uosc** menu is open (main menu, playlist, load/select subtitles,...):
- `up`, `down` - Select previous/next item.
- `left`, `right` - Back to parent menu or close, activate item.
- `enter` - Activate item.
- `enter` - Activate item or submenu.
- `bs` (backspace) - Activate parent menu.
- `esc` - Close menu.
- `wheel_up`, `wheel_down` - Scroll menu.
- `pgup`, `pgdwn`, `home`, `end` - Self explanatory.
- `ctrl+f` or `\` - In case `menu_type_to_search` is disabled, these two trigger the menu search instead.
- `ctrl+f` or `\` - In case `menu_type_to_search` config option is disabled, these two trigger the menu search instead.
- `ctrl+enter` - Submits a search in menus without instant search.
- `ctrl+backspace` - Delete search query by word.
- `shift+backspace` - Clear search query.
- `ctrl+up/down` - Move selected item in menus that support it (playlist).
- `ctrl+up/down/pgup/pgdwn/home/end` - Move selected item in menus that support it (playlist).
- `del` - Delete selected item in menus that support it (playlist).
- `shift+enter`, `shift+right` - Activate item without closing the menu.
- `shift+enter`, `shift+click` - Activate item without closing the menu. Might not be supported by all menus.
- `alt+enter`, `alt+click` - In file navigating menus, opens a directory in mpv instead of navigating to its contents.
Click on a faded parent menu to go back to it.
@@ -445,19 +446,6 @@ To see all the commands you can bind keys or menu items to, refer to [mpv's list
## Messages
### `uosc-version <version>`
Broadcasts the uosc version during script initialization. Useful if you want to detect that uosc is installed. Example:
```lua
-- Register response handler
mp.register_script_message('uosc-version', function(version)
print('uosc version', version)
end)
```
## Message handlers
**uosc** listens on some messages that can be sent with `script-message-to uosc` command. Example:
```
@@ -474,259 +462,12 @@ Parameters
ID (title) of the submenu, including `>` subsections as defined in `input.conf`. It has to be match the title exactly.
### `open-menu <menu_json> [submenu_id]`
## Scripting API
A message other scripts can send to open a uosc menu serialized as JSON. You can optionally pass a `submenu_id` to pre-open a submenu. The ID is the submenu title chain leading to the submenu concatenated with `>`, for example `Tools > Aspect ratio`.
Menu data structure:
```
Menu {
type?: string;
title?: string;
items: Item[];
selected_index?: integer;
keep_open?: boolean;
on_close?: string | string[];
on_search?: string | string[];
on_paste?: string | string[];
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
search_debounce?: 'submit' | number; // default: 0
search_suggestion?: string;
search_submenus?: boolean;
}
Item = Command | Submenu;
Submenu {
title?: string;
hint?: string;
items: Item[];
bold?: boolean;
italic?: boolean;
align?: 'left'|'center'|'right';
muted?: boolean;
separator?: boolean;
keep_open?: boolean;
on_search?: string | string[];
on_paste?: string | string[];
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
search_debounce?: 'submit' | number; // default: 0
search_suggestion?: string;
search_submenus?: boolean;
}
Command {
title?: string;
hint?: string;
icon?: string;
value: string | string[];
active?: integer;
selectable?: boolean;
bold?: boolean;
italic?: boolean;
align?: 'left'|'center'|'right';
muted?: boolean;
separator?: boolean;
keep_open?: boolean;
}
```
When `Command.value` is a string, it'll be passed to `mp.command(value)`. If it's a table (array) of strings, it'll be used as `mp.commandv(table.unpack(value))`. The same goes for `Menu.on_close` and `on_search`. `on_search` additionally appends the current search string as the last parameter.
`Menu.type` is used to refer to this menu in `update-menu` and `close-menu`.
While the menu is open this value will be available in `user-data/uosc/menu/type` and the `shared-script-properties` entry `uosc-menu-type`. If no type was provided, those will be set to `'undefined'`.
`search_style` can be:
- `on_demand` (_default_) - Search input pops up when user starts typing, or presses `/` or `ctrl+f`, depending on user configuration. It disappears on `shift+backspace`, or when input text is cleared.
- `palette` - Search input is always visible and can't be disabled. In this mode, menu `title` is used as input placeholder when no text has been entered yet.
- `disabled` - Menu can't be searched.
`search_debounce` controls how soon the search happens after the last character was entered in milliseconds. Entering new character resets the timer. Defaults to `300`. It can also have a special value `'submit'`, which triggers a search only after `ctrl+enter` was pressed.
`search_submenus` makes uosc's internal search handler (when no `on_search` callback is defined) look into submenus as well, effectively flattening the menu for the duration of the search. This property is inherited by all submenus.
`search_suggestion` fills menu search with initial query string. Useful for example when you want to implement something like subtitle downloader, you'd set it to current file name.
`item.icon` property accepts icon names. You can pick one from here: [Google Material Icons](https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded)\
There is also a special icon name `spinner` which will display a rotating spinner. Along with a no-op command on an item and `keep_open=true`, this can be used to display placeholder menus/items that are still loading.
`on_paste` is triggered when user pastes a string while menu is opened. Works the same as `on_search`.
When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child if child doesn't overwrite it.
It's usually not necessary to define `selected_index` as it'll default to the first `active` item, or 1st item in the list.
Example:
```lua
local utils = require('mp.utils')
local menu = {
type = 'menu_type',
title = 'Custom menu',
items = {
{title = 'Foo', hint = 'foo', value = 'quit'},
{title = 'Bar', hint = 'bar', value = 'quit', active = true},
}
}
local json = utils.format_json(menu)
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
```
### `update-menu <menu_json>`
Updates currently opened menu with the same `type`.
The difference between this and `open-menu` is that if the same type menu is already open, `open-menu` will reset the menu as if it was newly opened, while `update-menu` will update it's data.
`update-menu`, along with `{menu/item}.keep_open` property and `item.command` that sends a message back can be used to create a self updating menu with some limited UI. Example:
```lua
local utils = require('mp.utils')
local script_name = mp.get_script_name()
local state = {
checkbox = 'no',
radio = 'bar'
}
function command(str)
return string.format('script-message-to %s %s', script_name, str)
end
function create_menu_data()
return {
type = 'test_menu',
title = 'Test menu',
keep_open = true,
items = {
{
title = 'Checkbox',
icon = state.checkbox == 'yes' and 'check_box' or 'check_box_outline_blank',
value = command('set-state checkbox ' .. (state.checkbox == 'yes' and 'no' or 'yes'))
},
{
title = 'Radio',
hint = state.radio,
items = {
{
title = 'Foo',
icon = state.radio == 'foo' and 'radio_button_checked' or 'radio_button_unchecked',
value = command('set-state radio foo')
},
{
title = 'Bar',
icon = state.radio == 'bar' and 'radio_button_checked' or 'radio_button_unchecked',
value = command('set-state radio bar')
},
{
title = 'Baz',
icon = state.radio == 'baz' and 'radio_button_checked' or 'radio_button_unchecked',
value = command('set-state radio baz')
},
},
},
{
title = 'Submit',
icon = 'check',
value = command('submit'),
keep_open = false
},
}
}
end
mp.add_forced_key_binding('t', 'test_menu', function()
local json = utils.format_json(create_menu_data())
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
end)
mp.register_script_message('set-state', function(prop, value)
state[prop] = value
-- Update currently opened menu
local json = utils.format_json(create_menu_data())
mp.commandv('script-message-to', 'uosc', 'update-menu', json)
end)
mp.register_script_message('submit', function(prop, value)
-- Do something with state
end)
```
### `close-menu [type]`
Closes the menu. If the optional parameter `type` is provided, then the menu only
closes if it matches `Menu.type` of the currently open menu.
### `set <prop> <value>`
Tell **uosc** to set an external property to this value. Currently, this is only used to set/display control button active state and badges:
In your script, set the value of `foo` to `1`.
```lua
mp.commandv('script-message-to', 'uosc', 'set', 'foo', 1)
```
`foo` can now be used as a `toggle` or `cycle` property by specifying its owner with a `@{script_name}` suffix:
```
toggle:icon_name:foo@script_name
cycle:icon_name:foo@script_name:no/yes!
```
If user clicks this `toggle` or `cycle` button, uosc will send a `set` message back to the script owner. You can then listen to this message, do what you need with the new value, and update uosc state accordingly:
```lua
-- Send initial value so that the button has a correct active state
mp.commandv('script-message-to', 'uosc', 'set', 'foo', 'yes')
-- Listen for changes coming from `toggle` or `cycle` button
mp.register_script_message('set', function(prop, value)
-- ... do something with `value`
-- Update uosc external prop
mp.commandv('script-message-to', 'uosc', 'set', 'foo', value)
end)
```
External properties can also be used as control button badges:
```
controls=command:icon_name:command_name#foo@script_name?My foo button
```
### `overwrite-binding <name> <command>`
Allows a overwriting handling of uosc built in bindings. Useful for 3rd party scripts that specialize in a specific domain to replace built in menus or behaviors provided by existing bindings.
Example that reroutes uosc's basic stream quality menu to [christoph-heinrich/mpv-quality-menu](https://github.com/christoph-heinrich/mpv-quality-menu):
```lua
mp.commandv('script-message-to', 'uosc', 'overwrite-binding', 'stream-quality', 'script-binding quality_menu/video_formats_toggle')
```
To cancel the overwrite and return to default behavior, just omit the `<command>` parameter.
### `disable-elements <script_id> <element_ids>`
Set what uosc elements your script wants to disable. To cancel or re-enable them, send the message again with an empty string in place of `element_ids`.
```lua
mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(), 'timeline,volume')
```
Using `'user'` as `script_id` will overwrite user's `disable_elements` config. Elements will be enabled only when neither user, nor any script requested them to be disabled.
3rd party script developers can use our messaging API to integrate with uosc, or use it to render their menus. Documentation is available in [uosc Wiki](https://github.com/tomasklaen/uosc/wiki).
## Contributing
### Setup
If you want to test or work on something that involves ziggy (our multitool binary, currently handles searching & downloading subtitles), you first need to build it with:
```
tools/build ziggy
```
This requires [`go`](https://go.dev/dl/) to be installed and in path. If you don't want to bother with installing go, and there were no changes to ziggy, you can just use the binaries from [latest release](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip). Place folder `scripts/uosc/bin` from `uosc.zip` into `src/uosc/bin`.
### Localization
If you want to help localizing uosc by either adding a new locale or fixing one that is not up to date, start by running this while in the repository root:
@@ -741,6 +482,16 @@ This will parse the codebase for localization strings and use them to either upd
You can then navigate to `src/uosc/intl/languagecode.json` and start translating.
### Setting up binaries
If you want to test or work on something that involves ziggy (our multitool binary, currently handles searching & downloading subtitles), you first need to build it with:
```
tools/build ziggy
```
This requires [`go`](https://go.dev/dl/) to be installed and in path. If you don't want to bother with installing go, and there were no changes to ziggy, you can just use the binaries from [latest release](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip). Place folder `scripts/uosc/bin` from `uosc.zip` into `src/uosc/bin`.
## FAQ
#### Why is the release zip size in megabytes? Isn't this just a lua script?

File diff suppressed because it is too large Load Diff

View File

@@ -85,30 +85,56 @@ end
-- Tooltip.
---@param element Rect
---@param value string|number
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean}
---@param opts? {size?: number; align?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean; invert_colors?: boolean}
function ass_mt:tooltip(element, value, opts)
if value == '' then return end
opts = opts or {}
opts.size = opts.size or round(16 * state.scale)
opts.border = options.text_border * state.scale
opts.border_color = bg
opts.border_color = opts.invert_colors and fg or bg
opts.margin = opts.margin or round(10 * state.scale)
opts.lines = opts.lines or 1
opts.color = opts.invert_colors and bg or fg
local offset = opts.offset or 2
local padding_y = round(opts.size / 6)
local padding_x = round(opts.size / 3)
local offset = opts.offset or 2
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
local x = element.ax + (element.bx - element.ax) / 2
local y = align_top and element.ay - offset or element.by + offset
local width_half = (opts.width_overwrite or text_width(value, opts)) / 2 + padding_x
local min_edge_distance = width_half + opts.margin + Elements:v('window_border', 'size', 0)
x = clamp(min_edge_distance, x, display.width - min_edge_distance)
local ax, bx = round(x - width_half), round(x + width_half)
local ay = (align_top and y - opts.size * opts.lines - 2 * padding_y or y)
local by = (align_top and y or y + opts.size * opts.lines + 2 * padding_y)
self:rect(ax, ay, bx, by, {color = bg, opacity = config.opacity.tooltip, radius = state.radius})
local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
local height = opts.size * opts.lines + 2 * padding_y
local width_half, height_half = width / 2, height / 2
local margin = opts.margin + Elements:v('window_border', 'size', 0)
local align = opts.align or 8
local x, y = 0, 0 -- center of tooltip
-- Flip alignment to other side when not enough space
if opts.responsive ~= false then
if align == 8 then
if element.ay - offset - height < margin then align = 2 end
elseif align == 2 then
if element.by + offset + height > display.height - margin then align = 8 end
elseif align == 6 then
if element.bx + offset + width > display.width - margin then align = 4 end
elseif align == 4 then
if element.ax - offset - width < margin then align = 6 end
end
end
-- Calculate tooltip center based on alignment
if align == 8 or align == 2 then
x = clamp(margin + width_half, element.ax + (element.bx - element.ax) / 2, display.width - margin - width_half)
y = align == 8 and element.ay - offset - height_half or element.by + offset + height_half
else
x = align == 6 and element.bx + offset + width_half or element.ax - offset - width_half
y = clamp(margin + height_half, element.ay + (element.by - element.ay) / 2, display.height - margin - height_half)
end
-- Draw
local ax, ay, bx, by = round(x - width_half), round(y - height_half), round(x + width_half), round(y + height_half)
self:rect(ax, ay, bx, by, {
color = opts.invert_colors and fg or bg, opacity = config.opacity.tooltip, radius = state.radius
})
local func = opts.timestamp and self.timestamp or self.txt
func(self, x, align_top and y - padding_y or y + padding_y, align_top and 2 or 8, tostring(value), opts)
func(self, x, y, 5, tostring(value), opts)
return {ax = element.ax, ay = ay, bx = element.bx, by = by}
end

View File

@@ -1,6 +1,9 @@
---@param data MenuData
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
function open_command_menu(data, opts)
opts = opts or {}
local menu
local function run_command(command)
if type(command) == 'string' then
mp.command(command)
@@ -9,14 +12,21 @@ function open_command_menu(data, opts)
mp.commandv(unpack(command))
end
end
---@type MenuOptions
local menu_opts = {}
if opts then
menu_opts.mouse_nav = opts.mouse_nav
if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) 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
run_command(event.value)
menu:close()
end
end
local menu = Menu:open(data, run_command, menu_opts)
if opts and opts.submenu then menu:activate_submenu(opts.submenu) 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
@@ -29,7 +39,8 @@ function toggle_menu_with_items(opts)
end
end
---@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[])}
---@alias EventRemove {type: 'remove' | 'delete', index: number; value: any; menu_id: string;}
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[];on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: EventRemove); on_delete?: fun(event: EventRemove)}
function create_self_updating_menu_opener(opts)
return function()
if Menu:is_open(opts.type) then
@@ -64,30 +75,88 @@ function create_self_updating_menu_opener(opts)
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')}
actions[#actions + 1] = {name = 'move_down', icon = 'arrow_downward', label = t('Move down')}
end
if opts.on_remove or opts.on_delete then
local label = opts.on_remove and t('Remove') or t('Delete')
if opts.on_remove and opts.on_delete then label = t('Remove (ctrl to delete)') 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, menu_id = menu_id})
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, menu_id = menu_id})
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,
items = initial_items,
selected_index = selected_index,
on_paste = opts.on_paste,
},
opts.on_select, {
on_open = function()
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)
menu = Menu:open({
type = opts.type,
title = opts.title,
items = initial_items,
actions = actions,
selected_index = selected_index,
on_move = opts.on_move and 'callback' or nil,
on_close = 'callback',
}, 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)
menu:scroll_to_index(to_index, nil, true)
end
end,
on_close = function()
mp.unobserve_property(handle_list_prop_change)
mp.unobserve_property(handle_active_prop_change)
end,
on_move_item = opts.on_move_item,
on_delete_item = opts.on_delete_item,
})
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)
elseif itable_has({'', 'shift'}, event.modifiers) then
opts.on_activate(event --[[@as MenuEventActivate]])
if event.modifiers == 'shift' then menu:close() end
end
elseif event.type == 'key' then
if event.id == 'enter' then
menu:close()
elseif event.key == 'del' then
if itable_has({'', 'ctrl'}, event.modifiers) then
remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
end
end
elseif event.type == 'paste' and opts.on_paste then
opts.on_paste(event --[[@as MenuEventPaste]])
elseif event.type == 'close' then
mp.unobserve_property(handle_list_prop_change)
mp.unobserve_property(handle_active_prop_change)
menu: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
@@ -95,14 +164,16 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
local function serialize_tracklist(tracklist)
local items = {}
if download_command then
items[#items + 1] = {
title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
}
end
if load_command then
items[#items + 1] = {
title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}',
title = t('Load'),
bold = true,
italic = true,
hint = t('open file'),
value = '{load}',
actions = download_command
and {{name = 'download', icon = 'language', label = t('Search online')}}
or nil,
}
end
if #items > 0 then
@@ -163,16 +234,15 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
return items, active_index or first_item_index
end
local function handle_select(value)
if value == '{download}' then
mp.command(download_command)
elseif value == '{load}' then
mp.command(load_command)
---@param event MenuEventActivate
local function handle_activate(event)
if event.value == '{load}' then
mp.command(event.action == 'download' and download_command or load_command)
else
mp.commandv('set', track_prop, value and value or 'no')
mp.commandv('set', track_prop, event.value and event.value or 'no')
-- If subtitle track was selected, assume the user also wants to see it
if value and track_type == 'sub' then
if event.value and track_type == 'sub' then
mp.commandv('set', 'sub-visibility', 'yes')
end
end
@@ -183,92 +253,144 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
type = track_type,
list_prop = 'track-list',
serializer = serialize_tracklist,
on_select = handle_select,
on_paste = function(path) load_track(track_type, path) end,
on_activate = handle_activate,
on_paste = function(event) load_track(track_type, event.value) end,
})
end
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, 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_select fun(path: string, mods: Modifiers): nil
---@param handle_activate fun(event: MenuEventActivate)
---@param opts NavigationMenuOptions
function open_file_navigation_menu(directory_path, handle_select, opts)
directory = serialize_path(normalize_path(directory_path))
function open_file_navigation_menu(directory_path, handle_activate, opts)
opts = opts or {}
local current_directory = serialize_path(normalize_path(directory_path))
---@type Menu
local menu
---@type string | nil
local back_path
if not directory then
if not current_directory then
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
return
end
local separator = path_separator(current_directory.path)
local files, directories = read_directory(directory.path, {
types = opts.allowed_types,
hidden = options.show_hidden_files,
})
local is_root = not directory.dirname
local path_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 MenuStackValue, number
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 = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
})
local items, selected_index = {}, 1
if not files or not directories then return end
if process.status == 0 then
for _, value in ipairs(split(process.stdout, '\n')) do
local drive = string.match(value, 'Name=([A-Z]:)')
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
msg.error(process.stderr)
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}
end
else
items[#items + 1] = {title = '..', hint = t('parent dir'), value = directory.dirname, separator = true}
end
local back_path = items[#items] and items[#items].value
local selected_index = #items + 1
for _, dir in ipairs(directories) do
items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
end
for _, file in ipairs(files) do
items[#items + 1] = {title = file, value = join_path(directory.path, file)}
end
for index, item in ipairs(items) do
if not item.value.is_to_parent and opts.active_path == item.value then
item.active = true
if not opts.selected_path then selected_index = index end
return items, selected_index
end
if opts.selected_path == item.value then selected_index = index end
current_directory = serialize_path(path)
if not current_directory then
msg.error('Couldn\'t serialize path "' .. path .. '.')
return {}, 0
end
local files, directories = read_directory(current_directory.path, {
types = opts.allowed_types,
hidden = options.show_hidden_files,
})
local is_root = not current_directory.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}
end
else
items[#items + 1] = {title = '..', hint = t('parent dir'), value = current_directory.dirname, separator = 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, value = join_path(path, dir), hint = separator}
end
for _, file in ipairs(files) do
items[#items + 1] = {title = file, value = join_path(path, file)}
end
for index, item in ipairs(items) do
if not item.value.is_to_parent and 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
return items, selected_index
end
---@type MenuCallback
local function open_path(path, meta)
local items, selected_index = serialize_items(current_directory.path)
local menu_data = {
type = opts.type,
title = opts.title or current_directory.basename .. separator,
items = items,
on_close = opts.on_close and 'callback' or nil,
selected_index = selected_index,
}
local function open_directory(path)
local items, selected_index = serialize_items(path, current_directory.path)
menu_data.title = opts.title or current_directory.basename .. separator
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
local function activate(event)
local path = event.value
local is_drives = path == '{drives}'
local is_to_parent = is_drives or #path < #directory_path
local inheritable_options = {
type = opts.type,
title = opts.title,
allowed_types = opts.allowed_types,
active_path = opts.active_path,
keep_open = opts.keep_open,
}
if is_drives then
open_drives_menu(function(drive_path)
open_file_navigation_menu(drive_path, handle_select, inheritable_options)
end, {
type = inheritable_options.type,
title = inheritable_options.title,
selected_path = directory.path,
on_open = opts.on_open,
on_close = opts.on_close,
})
open_directory(path)
return
end
@@ -279,66 +401,24 @@ function open_file_navigation_menu(directory_path, handle_select, opts)
return
end
if info.is_dir and not meta.modifiers.alt and not meta.modifiers.ctrl then
-- Preselect directory we are coming from
if is_to_parent then
inheritable_options.selected_path = directory.path
end
open_file_navigation_menu(path, handle_select, inheritable_options)
if info.is_dir and event.modifiers == '' then
open_directory(path)
else
handle_select(path, meta.modifiers)
handle_activate(event)
if not opts.keep_open then close() end
end
end
local function handle_back()
if back_path then open_path(back_path, {modifiers = {}}) end
end
local menu_data = {
type = opts.type,
title = opts.title or directory.basename .. path_separator,
items = items,
keep_open = opts.keep_open,
selected_index = selected_index,
}
local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
return Menu:open(menu_data, open_path, menu_options)
end
-- Opens a file navigation menu with Windows drives as items.
---@param handle_select fun(path: string): nil
---@param opts? NavigationMenuOptions
function open_drives_menu(handle_select, opts)
opts = opts or {}
local process = mp.command_native({
name = 'subprocess',
capture_stdout = true,
playback_only = false,
args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
})
local items, selected_index = {}, 1
if process.status == 0 then
for _, value in ipairs(split(process.stdout, '\n')) do
local drive = string.match(value, 'Name=([A-Z]:)')
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 opts.selected_path == drive_path then selected_index = #items end
end
menu = Menu:open(menu_data, function(event)
if event.type == 'activate' then
activate(event --[[@as MenuEventActivate]])
elseif event.type == 'back' then
if back_path then open_directory(back_path) end
elseif event.type == 'close' then
close()
end
else
msg.error(process.stderr)
end
end)
return Menu:open(
{type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index},
handle_select
)
return menu
end
-- On demand menu items loading
@@ -520,34 +600,40 @@ function open_stream_quality_menu()
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:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
mp.set_property('ytdl-format', format)
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')
-- 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')
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)
-- 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
mp.register_event('file-loaded', seeker)
if event.modifiers ~= 'shift' then menu:close() end
end
end)
end
@@ -558,6 +644,8 @@ function open_open_file_menu()
return
end
---@type Menu | nil
local menu
local directory
local active_file
@@ -581,7 +669,6 @@ function open_open_file_menu()
end
-- Update active file in directory navigation menu
local menu = nil
local function handle_file_loaded()
if menu and menu:is_alive() then
menu:activate_one_value(normalize_path(mp.get_property_native('path')))
@@ -590,13 +677,14 @@ function open_open_file_menu()
menu = open_file_navigation_menu(
directory,
function(path, mods)
local command = has_any_extension(path, config.types.playlist) and 'loadlist' or 'loadfile'
if mods.ctrl then
mp.commandv(command, path, 'append')
else
mp.commandv(command, path)
Menu:close()
function(event)
if not menu then return end
local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
if itable_has({'ctrl', 'ctrl+shift'}, event.modifiers) then
mp.commandv(command, event.value, 'append')
elseif event.modifiers == '' then
mp.commandv(command, event.value)
menu:close()
end
end,
{
@@ -604,10 +692,10 @@ function open_open_file_menu()
allowed_types = config.types.media,
active_path = active_file,
keep_open = true,
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
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 {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
@@ -638,9 +726,11 @@ function create_track_loader_menu_opener(opts)
path = get_default_directory()
end
local function handle_select(path) load_track(opts.prop, path) end
local function handle_activate(event)
if event.modifiers == '' then load_track(opts.prop, event.value) end
end
open_file_navigation_menu(path, handle_select, {
open_file_navigation_menu(path, handle_activate, {
type = menu_type, title = title, allowed_types = opts.allowed_types,
})
end
@@ -725,7 +815,11 @@ function open_subtitle_downloader()
type = menu_type .. '-result',
search_style = 'disabled',
items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
}, function() end)
}, function(event)
if event.type == 'key' and event.key == 'enter' then
menu:close()
end
end)
local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
'--file-id', tostring(data.id),
@@ -876,10 +970,16 @@ function open_subtitle_downloader()
title = t('enter query'),
items = initial_items,
search_style = 'palette',
on_search = handle_search,
on_search = 'callback',
search_debounce = 'submit',
search_suggestion = search_suggestion,
},
handle_select
function(event)
if event.type == 'activate' then
handle_select(event.value)
elseif event.type == 'search' then
handle_search(event.query)
end
end
)
end

View File

@@ -222,6 +222,19 @@ function table_assign_props(target, source, props)
return target
end
-- Assign props from `source` to `target` that are not in `props` set.
---@generic T: table<any, any>
---@param target T
---@param source T
---@param props table<string, boolean>
---@return T
function table_assign_exclude(target, source, props)
for key, value in pairs(source) do
if not props[key] then target[key] = value end
end
return target
end
-- `table_assign({}, input)` without loosing types :(
---@generic T: table<any, any>
---@param input T

View File

@@ -4,6 +4,8 @@
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
---@alias Circle {point: Point, r: number, window_drag?: boolean}
---@alias Hitbox Rect|Circle
---@alias Shortcut {id: string; key: string; modifiers: string; alt: boolean; ctrl: boolean; shift: boolean}
---@alias ComplexBindingInfo {event: 'down' | 'repeat' | 'up' | 'press'; is_mouse: boolean; canceled: boolean; key_name?: string; key_text?: string;}
--- In place sorting of filenames
---@param filenames string[]
@@ -398,6 +400,26 @@ function has_any_extension(path, extensions)
return false
end
---@param key string
---@param modifiers? string
---@return Shortcut
function create_shortcut(key, modifiers)
key = key:lower()
local id_parts, modifiers_set
if modifiers then
id_parts = split(modifiers:lower(), '+')
table.sort(id_parts, function(a, b) return a < b end)
modifiers_set = create_set(id_parts)
modifiers = table.concat(id_parts, '+')
else
id_parts, modifiers, modifiers_set = {}, '', {}
end
id_parts[#id_parts + 1] = key
return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
end
-- Executes mp command defined as a string or an itable, or does nothing if command is any other value.
-- Returns boolean specifying if command was executed or not.
---@param command string | string[] | nil | any

View File

@@ -898,11 +898,12 @@ bind_command('playlist', create_self_updating_menu_opener({
end
return items
end,
on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end,
on_move_item = function(from, to)
mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1))
on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
on_move = function(event)
local from, to = event.from_index, event.to_index
mp.commandv('playlist-move', tostring(from - 1), tostring(to - (to > from and 0 or 1)))
end,
on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end,
on_remove = function(event) mp.commandv('playlist-remove', tostring(event.index - 1)) end,
}))
bind_command('chapters', create_self_updating_menu_opener({
title = t('Chapters'),
@@ -922,7 +923,7 @@ bind_command('chapters', create_self_updating_menu_opener({
end
return items
end,
on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
on_activate = function(event) mp.commandv('set', 'chapter', tostring(event.value - 1)) end,
}))
bind_command('editions', create_self_updating_menu_opener({
title = t('Editions'),
@@ -942,7 +943,7 @@ bind_command('editions', create_self_updating_menu_opener({
end
return items
end,
on_select = function(id) mp.commandv('set', 'edition', id) end,
on_activate = function(event) mp.commandv('set', 'edition', event.value) end,
}))
bind_command('show-in-directory', function()
-- Ignore URLs
@@ -1023,7 +1024,7 @@ bind_command('audio-device', create_self_updating_menu_opener({
end
return items
end,
on_select = function(name) mp.commandv('set', 'audio-device', name) end,
on_activate = function(event) mp.commandv('set', 'audio-device', event.value) end,
}))
bind_command('open-config-directory', function()
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
@@ -1072,6 +1073,17 @@ mp.register_script_message('update-menu', function(json)
if menu then menu:update(data) end
end
end)
mp.register_script_message('select-menu-item', function(type, item_index, menu_id)
local menu = Menu:is_open(type)
local index = tonumber(item_index)
if menu and index and not menu.mouse_nav then
index = round(index)
if index > 0 and index <= #menu.current.items then
menu:select_index(index, menu_id)
menu:scroll_to_index(index, menu_id, true)
end
end
end)
mp.register_script_message('close-menu', function(type)
if Menu:is_open(type) then Menu:close() end
end)