feat: menu lifecycle and actions API (#936)
This commit is contained in:
289
README.md
289
README.md
@@ -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
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user