Compare commits
25 Commits
dev-sane-5
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
61c259b137 | ||
![]() |
e975429086 | ||
![]() |
5f701436d1 | ||
![]() |
7f054a45f8 | ||
![]() |
a0a608d451 | ||
![]() |
8346db433f | ||
![]() |
fe0d394435 | ||
![]() |
9fa7220da4 | ||
![]() |
c660db0743 | ||
![]() |
3bf774bf54 | ||
![]() |
1c38d4d3a7 | ||
![]() |
08df4aa944 | ||
![]() |
b01c6df787 | ||
![]() |
43dcb51eb9 | ||
![]() |
0e60272db1 | ||
![]() |
2940352fad | ||
![]() |
d4b1343609 | ||
![]() |
89360096c6 | ||
![]() |
e15523a56e | ||
![]() |
4c64fe9d52 | ||
![]() |
7f03046223 | ||
![]() |
a7136337c8 | ||
![]() |
56b62bb0b2 | ||
![]() |
00737e1598 | ||
![]() |
e6a5fd981d |
291
README.md
291
README.md
@@ -27,8 +27,9 @@ Features:
|
|||||||
- Speed bar: change speed by `speed_step` per scroll.
|
- 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`.
|
- 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.
|
- Right click on volume or speed elements to reset them.
|
||||||
- Transform chapters into timeline ranges (the red portion of the timeline in the preview).
|
- Transforming 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.
|
- 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).
|
[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,...):
|
These bindings are active when any **uosc** menu is open (main menu, playlist, load/select subtitles,...):
|
||||||
|
|
||||||
- `up`, `down` - Select previous/next item.
|
- `up`, `down` - Select previous/next item.
|
||||||
- `left`, `right` - Back to parent menu or close, activate item.
|
- `enter` - Activate item or submenu.
|
||||||
- `enter` - Activate item.
|
- `bs` (backspace) - Activate parent menu.
|
||||||
- `esc` - Close menu.
|
- `esc` - Close menu.
|
||||||
- `wheel_up`, `wheel_down` - Scroll menu.
|
- `wheel_up`, `wheel_down` - Scroll menu.
|
||||||
- `pgup`, `pgdwn`, `home`, `end` - Self explanatory.
|
- `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+enter` - Submits a search in menus without instant search.
|
||||||
- `ctrl+backspace` - Delete search query by word.
|
- `ctrl+backspace` - Delete search query by word.
|
||||||
- `shift+backspace` - Clear search query.
|
- `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).
|
- `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.
|
- `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.
|
Click on a faded parent menu to go back to it.
|
||||||
@@ -251,7 +252,7 @@ Switch stream quality. This is just a basic re-assignment of `ytdl-format` mpv p
|
|||||||
|
|
||||||
#### `keybinds`
|
#### `keybinds`
|
||||||
|
|
||||||
Displays a command palette menu with all key bindings defined in your `input.conf` file. Useful to check what command is bound to what shortcut, or the other way around.
|
Displays a command palette menu with all currently active keybindings (defined in your `input.conf` file, or registered by scripts). Useful to check what command is bound to what shortcut, or the other way around.
|
||||||
|
|
||||||
#### `open-file`
|
#### `open-file`
|
||||||
|
|
||||||
@@ -445,19 +446,6 @@ To see all the commands you can bind keys or menu items to, refer to [mpv's list
|
|||||||
|
|
||||||
## Messages
|
## 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:
|
**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.
|
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`.
|
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).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Contributing
|
## 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
|
### 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:
|
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.
|
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
|
## FAQ
|
||||||
|
|
||||||
#### Why is the release zip size in megabytes? Isn't this just a lua script?
|
#### Why is the release zip size in megabytes? Isn't this just a lua script?
|
||||||
|
@@ -43,6 +43,7 @@ progress_line_width=20
|
|||||||
# {scale} - factor of controls_size, default: 0.3
|
# {scale} - factor of controls_size, default: 0.3
|
||||||
# `space` - fills all available space between previous and next item, useful to align items to the right
|
# `space` - fills all available space between previous and next item, useful to align items to the right
|
||||||
# - multiple spaces divide the available space among themselves, which can be used for centering
|
# - multiple spaces divide the available space among themselves, which can be used for centering
|
||||||
|
# `button:{name}` - button whose state, look, and click action are managed by external script
|
||||||
# Item visibility control:
|
# Item visibility control:
|
||||||
# `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility
|
# `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility
|
||||||
# - `{disposition}` can be one of:
|
# - `{disposition}` can be one of:
|
||||||
@@ -109,7 +110,8 @@ menu_type_to_search=yes
|
|||||||
# Can be: never, no-border, always
|
# Can be: never, no-border, always
|
||||||
top_bar=no-border
|
top_bar=no-border
|
||||||
top_bar_size=40
|
top_bar_size=40
|
||||||
top_bar_controls=yes
|
# Can be: `no` (hide), left or right
|
||||||
|
top_bar_controls=right
|
||||||
# Can be: `no` (hide), `yes` (inherit title from mpv.conf), or a custom template string
|
# Can be: `no` (hide), `yes` (inherit title from mpv.conf), or a custom template string
|
||||||
top_bar_title=yes
|
top_bar_title=yes
|
||||||
# Template string to enable alternative top bar title. If alt title matches main title,
|
# Template string to enable alternative top bar title. If alt title matches main title,
|
||||||
@@ -120,7 +122,7 @@ top_bar_alt_title=
|
|||||||
# `toggle` => toggle the top bar title text between main and alt by clicking
|
# `toggle` => toggle the top bar title text between main and alt by clicking
|
||||||
# the top bar, or calling `toggle-title` binding
|
# the top bar, or calling `toggle-title` binding
|
||||||
top_bar_alt_title_place=below
|
top_bar_alt_title_place=below
|
||||||
# Flash top bar when any of these file types is loaded. Available: audio,image,video
|
# Flash top bar when any of these file types is loaded. Available: audio,video,image,chapter
|
||||||
top_bar_flash_on=video,audio
|
top_bar_flash_on=video,audio
|
||||||
top_bar_persistency=
|
top_bar_persistency=
|
||||||
|
|
||||||
@@ -191,7 +193,8 @@ video_types=3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg
|
|||||||
audio_types=aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv
|
audio_types=aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv
|
||||||
image_types=apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp
|
image_types=apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp
|
||||||
subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt
|
subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt
|
||||||
# Default open-file menu directory
|
playlist_types=m3u,m3u8,pls,url,cue
|
||||||
|
# Default open-file menu directory. Use `{drives}` to open drives menu on windows (defaults to `/` on unix).
|
||||||
default_directory=~/
|
default_directory=~/
|
||||||
# List hidden files when reading directories. Due to environment limitations, this currently only hides
|
# List hidden files when reading directories. Due to environment limitations, this currently only hides
|
||||||
# files starting with a dot. Doesn't hide hidden files on windows (we have no way to tell they're hidden).
|
# files starting with a dot. Doesn't hide hidden files on windows (we have no way to tell they're hidden).
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
local Element = require('elements/Element')
|
local Element = require('elements/Element')
|
||||||
|
|
||||||
---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
|
---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
|
||||||
|
|
||||||
---@class Button : Element
|
---@class Button : Element
|
||||||
local Button = class(Element)
|
local Button = class(Element)
|
||||||
@@ -17,13 +17,15 @@ function Button:init(id, props)
|
|||||||
self.badge = props.badge
|
self.badge = props.badge
|
||||||
self.foreground = props.foreground or fg
|
self.foreground = props.foreground or fg
|
||||||
self.background = props.background or bg
|
self.background = props.background or bg
|
||||||
---@type fun()
|
self.is_clickable = true
|
||||||
|
---@type fun()|nil
|
||||||
self.on_click = props.on_click
|
self.on_click = props.on_click
|
||||||
Element.init(self, id, props)
|
Element.init(self, id, props)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
||||||
function Button:handle_cursor_click()
|
function Button:handle_cursor_click()
|
||||||
|
if not self.on_click or not self.is_clickable then return end
|
||||||
-- We delay the callback to next tick, otherwise we are risking race
|
-- We delay the callback to next tick, otherwise we are risking race
|
||||||
-- conditions as we are in the middle of event dispatching.
|
-- conditions as we are in the middle of event dispatching.
|
||||||
-- For example, handler might add a menu to the end of the element stack, and that
|
-- For example, handler might add a menu to the end of the element stack, and that
|
||||||
@@ -37,17 +39,20 @@ function Button:render()
|
|||||||
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
|
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
|
||||||
|
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
|
local is_clickable = self.is_clickable and self.on_click ~= nil
|
||||||
local is_hover = self.proximity_raw == 0
|
local is_hover = self.proximity_raw == 0
|
||||||
local is_hover_or_active = is_hover or self.active
|
|
||||||
local foreground = self.active and self.background or self.foreground
|
local foreground = self.active and self.background or self.foreground
|
||||||
local background = self.active and self.foreground or self.background
|
local background = self.active and self.foreground or self.background
|
||||||
|
local background_opacity = self.active and 1 or config.opacity.controls
|
||||||
|
|
||||||
|
if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
|
||||||
|
|
||||||
-- Background
|
-- Background
|
||||||
if is_hover_or_active or config.opacity.controls > 0 then
|
if background_opacity > 0 then
|
||||||
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
ass:rect(self.ax, self.ay, self.bx, self.by, {
|
||||||
color = (self.active or not is_hover) and background or foreground,
|
color = (self.active or not is_hover) and background or foreground,
|
||||||
radius = state.radius,
|
radius = state.radius,
|
||||||
opacity = visibility * (self.active and 1 or (is_hover and 0.3 or config.opacity.controls)),
|
opacity = visibility * background_opacity,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
local Element = require('elements/Element')
|
local Element = require('elements/Element')
|
||||||
local Button = require('elements/Button')
|
local Button = require('elements/Button')
|
||||||
local CycleButton = require('elements/CycleButton')
|
local CycleButton = require('elements/CycleButton')
|
||||||
|
local ManagedButton = require('elements/ManagedButton')
|
||||||
local Speed = require('elements/Speed')
|
local Speed = require('elements/Speed')
|
||||||
|
|
||||||
-- sizing:
|
-- sizing:
|
||||||
@@ -163,6 +164,19 @@ function Controls:init_options()
|
|||||||
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||||
if badge then self:register_badge_updater(badge, element) end
|
if badge then self:register_badge_updater(badge, element) end
|
||||||
end
|
end
|
||||||
|
elseif kind == 'button' then
|
||||||
|
if #params ~= 1 then
|
||||||
|
mp.error(string.format(
|
||||||
|
'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
|
||||||
|
))
|
||||||
|
else
|
||||||
|
local element = ManagedButton:new('control_' .. i, {
|
||||||
|
name = params[1],
|
||||||
|
render_order = self.render_order,
|
||||||
|
anchor_id = 'controls',
|
||||||
|
})
|
||||||
|
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||||
|
end
|
||||||
elseif kind == 'speed' then
|
elseif kind == 'speed' then
|
||||||
if not Elements.speed then
|
if not Elements.speed then
|
||||||
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
|
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
|
||||||
|
@@ -35,6 +35,12 @@ function CycleButton:init(id, props)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function handle_change(name, value)
|
local function handle_change(name, value)
|
||||||
|
-- Removes unnecessary floating point digits from values like `2.00000`.
|
||||||
|
-- This happens when observing properties like `speed`.
|
||||||
|
if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
|
||||||
|
value = tonumber(value)
|
||||||
|
end
|
||||||
|
|
||||||
value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
|
value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
|
||||||
local index = itable_find(self.states, function(state) return state.value == value end)
|
local index = itable_find(self.states, function(state) return state.value == value end)
|
||||||
self.current_state_index = index or 1
|
self.current_state_index = index or 1
|
||||||
|
@@ -35,7 +35,7 @@ function Element:init(id, props)
|
|||||||
local function getTo() return self.proximity end
|
local function getTo() return self.proximity end
|
||||||
local function onTweenEnd() self.forced_visibility = nil end
|
local function onTweenEnd() self.forced_visibility = nil end
|
||||||
if self.enabled then
|
if self.enabled then
|
||||||
self:tween_property('forced_visibility', 1, getTo, onTweenEnd)
|
self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
|
||||||
else
|
else
|
||||||
onTweenEnd()
|
onTweenEnd()
|
||||||
end
|
end
|
||||||
|
29
src/uosc/elements/ManagedButton.lua
Normal file
29
src/uosc/elements/ManagedButton.lua
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
local Button = require('elements/Button')
|
||||||
|
|
||||||
|
---@alias ManagedButtonProps {name: string; anchor_id?: string; render_order?: number}
|
||||||
|
|
||||||
|
---@class ManagedButton : Button
|
||||||
|
local ManagedButton = class(Button)
|
||||||
|
|
||||||
|
---@param id string
|
||||||
|
---@param props ManagedButtonProps
|
||||||
|
function ManagedButton:new(id, props) return Class.new(self, id, props) --[[@as ManagedButton]] end
|
||||||
|
---@param id string
|
||||||
|
---@param props ManagedButtonProps
|
||||||
|
function ManagedButton:init(id, props)
|
||||||
|
---@type string | table | nil
|
||||||
|
self.command = nil
|
||||||
|
|
||||||
|
Button.init(self, id, table_assign({}, props, {on_click = function() execute_command(self.command) end}))
|
||||||
|
|
||||||
|
self:register_disposer(buttons:subscribe(props.name, function(data) self:update(data) end))
|
||||||
|
end
|
||||||
|
|
||||||
|
function ManagedButton:update(data)
|
||||||
|
for _, prop in ipairs({'icon', 'active', 'badge', 'command', 'tooltip'}) do
|
||||||
|
self[prop] = data[prop]
|
||||||
|
end
|
||||||
|
self.is_clickable = self.command ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return ManagedButton
|
File diff suppressed because it is too large
Load Diff
@@ -163,8 +163,6 @@ function Timeline:on_global_mouse_move()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
function Timeline:handle_wheel_up() mp.commandv('seek', options.timeline_step) end
|
|
||||||
function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) end
|
|
||||||
|
|
||||||
function Timeline:render()
|
function Timeline:render()
|
||||||
if self.size == 0 then return end
|
if self.size == 0 then return end
|
||||||
@@ -186,8 +184,10 @@ function Timeline:render()
|
|||||||
self:handle_cursor_down()
|
self:handle_cursor_down()
|
||||||
cursor:once('primary_up', function() self:handle_cursor_up() end)
|
cursor:once('primary_up', function() self:handle_cursor_up() end)
|
||||||
end)
|
end)
|
||||||
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
|
if options.timeline_step ~= 0 then
|
||||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
cursor:zone('wheel_down', self, function() mp.commandv('seek', -options.timeline_step) end)
|
||||||
|
cursor:zone('wheel_up', self, function() mp.commandv('seek', options.timeline_step) end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
@@ -251,15 +251,11 @@ function Timeline:render()
|
|||||||
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
|
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
|
||||||
|
|
||||||
-- Uncached ranges
|
-- Uncached ranges
|
||||||
local buffered_playtime = nil
|
|
||||||
if state.uncached_ranges then
|
if state.uncached_ranges then
|
||||||
local opts = {size = 80, anchor_y = fby}
|
local opts = {size = 80, anchor_y = fby}
|
||||||
local texture_char = visibility > 0 and 'b' or 'a'
|
local texture_char = visibility > 0 and 'b' or 'a'
|
||||||
local offset = opts.size / (visibility > 0 and 24 or 28)
|
local offset = opts.size / (visibility > 0 and 24 or 28)
|
||||||
for _, range in ipairs(state.uncached_ranges) do
|
for _, range in ipairs(state.uncached_ranges) do
|
||||||
if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then
|
|
||||||
buffered_playtime = (range[1] - state.time) / state.speed
|
|
||||||
end
|
|
||||||
if options.timeline_cache then
|
if options.timeline_cache then
|
||||||
local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
|
local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
|
||||||
local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
|
local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
|
||||||
@@ -321,7 +317,7 @@ function Timeline:render()
|
|||||||
if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
|
if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
|
||||||
local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
|
local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
|
||||||
if visibility > 0 then
|
if visibility > 0 then
|
||||||
cursor:zone('primary_down', circle, function()
|
cursor:zone('primary_click', circle, function()
|
||||||
mp.commandv('seek', chapter.time, 'absolute+exact')
|
mp.commandv('seek', chapter.time, 'absolute+exact')
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -377,14 +373,15 @@ function Timeline:render()
|
|||||||
if text_opacity > 0 then
|
if text_opacity > 0 then
|
||||||
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
|
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
|
||||||
-- Upcoming cache time
|
-- Upcoming cache time
|
||||||
if buffered_playtime and options.buffered_time_threshold > 0
|
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
|
||||||
and buffered_playtime < options.buffered_time_threshold then
|
if cache_duration and options.buffered_time_threshold > 0
|
||||||
|
and cache_duration < options.buffered_time_threshold then
|
||||||
local margin = 5 * state.scale
|
local margin = 5 * state.scale
|
||||||
local x, align = fbx + margin, 4
|
local x, align = fbx + margin, 4
|
||||||
local cache_opts = {
|
local cache_opts = {
|
||||||
size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
|
size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
|
||||||
}
|
}
|
||||||
local human = round(math.max(buffered_playtime, 0)) .. 's'
|
local human = round(cache_duration) .. 's'
|
||||||
local width = text_width(human, cache_opts)
|
local width = text_width(human, cache_opts)
|
||||||
local time_width = timestamp_width(state.time_human, time_opts)
|
local time_width = timestamp_width(state.time_human, time_opts)
|
||||||
local time_width_end = timestamp_width(state.destination_time_human, time_opts)
|
local time_width_end = timestamp_width(state.destination_time_human, time_opts)
|
||||||
|
@@ -1,46 +1,6 @@
|
|||||||
local Element = require('elements/Element')
|
local Element = require('elements/Element')
|
||||||
|
|
||||||
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
|
---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
|
||||||
|
|
||||||
---@class TopBarButton : Element
|
|
||||||
local TopBarButton = class(Element)
|
|
||||||
|
|
||||||
---@param id string
|
|
||||||
---@param props TopBarButtonProps
|
|
||||||
function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
|
|
||||||
function TopBarButton:init(id, props)
|
|
||||||
Element.init(self, id, props)
|
|
||||||
self.anchor_id = 'top_bar'
|
|
||||||
self.icon = props.icon
|
|
||||||
self.background = props.background
|
|
||||||
self.command = props.command
|
|
||||||
end
|
|
||||||
|
|
||||||
function TopBarButton:handle_click()
|
|
||||||
mp.command(type(self.command) == 'function' and self.command() or self.command)
|
|
||||||
end
|
|
||||||
|
|
||||||
function TopBarButton:render()
|
|
||||||
local visibility = self:get_visibility()
|
|
||||||
if visibility <= 0 then return end
|
|
||||||
local ass = assdraw.ass_new()
|
|
||||||
|
|
||||||
-- Background on hover
|
|
||||||
if self.proximity_raw == 0 then
|
|
||||||
ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
|
|
||||||
end
|
|
||||||
cursor:zone('primary_click', self, function() self:handle_click() end)
|
|
||||||
|
|
||||||
local width, height = self.bx - self.ax, self.by - self.ay
|
|
||||||
local icon_size = math.min(width, height) * 0.5
|
|
||||||
ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
|
|
||||||
opacity = visibility, border = options.text_border * state.scale,
|
|
||||||
})
|
|
||||||
|
|
||||||
return ass
|
|
||||||
end
|
|
||||||
|
|
||||||
--[[ TopBar ]]
|
|
||||||
|
|
||||||
---@class TopBar : Element
|
---@class TopBar : Element
|
||||||
local TopBar = class(Element)
|
local TopBar = class(Element)
|
||||||
@@ -49,48 +9,30 @@ function TopBar:new() return Class.new(self) --[[@as TopBar]] end
|
|||||||
function TopBar:init()
|
function TopBar:init()
|
||||||
Element.init(self, 'top_bar', {render_order = 4})
|
Element.init(self, 'top_bar', {render_order = 4})
|
||||||
self.size = 0
|
self.size = 0
|
||||||
self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1
|
self.icon_size, self.font_size, self.title_by = 1, 1, 1
|
||||||
self.show_alt_title = false
|
self.show_alt_title = false
|
||||||
self.main_title, self.alt_title = nil, nil
|
self.main_title, self.alt_title = nil, nil
|
||||||
|
|
||||||
local function get_maximized_command()
|
local function maximized_command()
|
||||||
if state.platform == 'windows' then
|
if state.platform == 'windows' then
|
||||||
return state.border
|
mp.command(state.border
|
||||||
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
|
||||||
or 'set window-maximized no;cycle fullscreen'
|
or 'set window-maximized no;cycle fullscreen')
|
||||||
|
else
|
||||||
|
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
|
||||||
end
|
end
|
||||||
return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Order aligns from right to left
|
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
|
||||||
self.buttons = {
|
local max = {icon = 'crop_square', command = maximized_command}
|
||||||
TopBarButton:new('tb_close', {
|
local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
|
||||||
icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order,
|
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
|
||||||
}),
|
|
||||||
TopBarButton:new('tb_max', {
|
|
||||||
icon = 'crop_square',
|
|
||||||
background = '222222',
|
|
||||||
command = get_maximized_command,
|
|
||||||
render_order = self.render_order,
|
|
||||||
}),
|
|
||||||
TopBarButton:new('tb_min', {
|
|
||||||
icon = 'minimize',
|
|
||||||
background = '222222',
|
|
||||||
command = 'cycle window-minimized',
|
|
||||||
render_order = self.render_order,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
self:decide_titles()
|
self:decide_titles()
|
||||||
self:decide_enabled()
|
self:decide_enabled()
|
||||||
self:update_dimensions()
|
self:update_dimensions()
|
||||||
end
|
end
|
||||||
|
|
||||||
function TopBar:destroy()
|
|
||||||
for _, button in ipairs(self.buttons) do button:destroy() end
|
|
||||||
Element.destroy(self)
|
|
||||||
end
|
|
||||||
|
|
||||||
function TopBar:decide_enabled()
|
function TopBar:decide_enabled()
|
||||||
if options.top_bar == 'no-border' then
|
if options.top_bar == 'no-border' then
|
||||||
self.enabled = not state.border or state.title_bar == false or state.fullscreen
|
self.enabled = not state.border or state.title_bar == false or state.fullscreen
|
||||||
@@ -98,9 +40,6 @@ function TopBar:decide_enabled()
|
|||||||
self.enabled = options.top_bar == 'always'
|
self.enabled = options.top_bar == 'always'
|
||||||
end
|
end
|
||||||
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
|
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
|
||||||
for _, element in ipairs(self.buttons) do
|
|
||||||
element.enabled = self.enabled and options.top_bar_controls
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TopBar:decide_titles()
|
function TopBar:decide_titles()
|
||||||
@@ -136,22 +75,12 @@ end
|
|||||||
function TopBar:update_dimensions()
|
function TopBar:update_dimensions()
|
||||||
self.size = round(options.top_bar_size * state.scale)
|
self.size = round(options.top_bar_size * state.scale)
|
||||||
self.icon_size = round(self.size * 0.5)
|
self.icon_size = round(self.size * 0.5)
|
||||||
self.spacing = math.ceil(self.size * 0.25)
|
self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
|
||||||
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
|
|
||||||
self.button_width = round(self.size * 1.15)
|
|
||||||
local window_border_size = Elements:v('window_border', 'size', 0)
|
local window_border_size = Elements:v('window_border', 'size', 0)
|
||||||
|
self.ax = window_border_size
|
||||||
self.ay = window_border_size
|
self.ay = window_border_size
|
||||||
self.bx = display.width - window_border_size
|
self.bx = display.width - window_border_size
|
||||||
self.by = self.size + window_border_size
|
self.by = self.size + window_border_size
|
||||||
self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
|
|
||||||
self.ax = (options.top_bar_title ~= 'no' or state.has_playlist) and window_border_size or self.title_bx
|
|
||||||
|
|
||||||
local button_bx = self.bx
|
|
||||||
for _, element in pairs(self.buttons) do
|
|
||||||
element.ax, element.bx = button_bx - self.button_width, button_bx
|
|
||||||
element.ay, element.by = self.ay, self.by
|
|
||||||
button_bx = button_bx - self.button_width
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TopBar:toggle_title()
|
function TopBar:toggle_title()
|
||||||
@@ -199,15 +128,54 @@ function TopBar:render()
|
|||||||
local visibility = self:get_visibility()
|
local visibility = self:get_visibility()
|
||||||
if visibility <= 0 then return end
|
if visibility <= 0 then return end
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
|
local ax, bx = self.ax, self.bx
|
||||||
|
local margin = math.floor((self.size - self.font_size) / 4)
|
||||||
|
|
||||||
|
-- Window controls
|
||||||
|
if options.top_bar_controls then
|
||||||
|
local is_left, button_ax = options.top_bar_controls == 'left', 0
|
||||||
|
if is_left then
|
||||||
|
button_ax = ax
|
||||||
|
ax = self.size * #self.buttons
|
||||||
|
else
|
||||||
|
button_ax = bx - self.size * #self.buttons
|
||||||
|
bx = button_ax
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, button in ipairs(self.buttons) do
|
||||||
|
local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
|
||||||
|
local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||||
|
local opacity = is_hover and 1 or config.opacity.controls
|
||||||
|
local button_fg = is_hover and (button.hover_fg or bg) or fg
|
||||||
|
local button_bg = is_hover and (button.hover_bg or fg) or bg
|
||||||
|
|
||||||
|
cursor:zone('primary_click', rect, button.command)
|
||||||
|
|
||||||
|
local bg_size = self.size - margin
|
||||||
|
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
|
||||||
|
local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
|
||||||
|
|
||||||
|
ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
|
||||||
|
color = button_bg, opacity = visibility * opacity, radius = state.radius,
|
||||||
|
})
|
||||||
|
|
||||||
|
ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
|
||||||
|
color = button_fg,
|
||||||
|
border_color = button_bg,
|
||||||
|
opacity = visibility,
|
||||||
|
border = options.text_border * state.scale,
|
||||||
|
})
|
||||||
|
|
||||||
|
button_ax = button_ax + self.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Window title
|
-- Window title
|
||||||
if state.title or state.has_playlist then
|
if state.title or state.has_playlist then
|
||||||
local bg_margin = math.floor((self.size - self.font_size) / 4)
|
|
||||||
local padding = self.font_size / 2
|
local padding = self.font_size / 2
|
||||||
local spacing = 1
|
local spacing = 1
|
||||||
local title_ax = self.ax + bg_margin
|
local left_aligned = options.top_bar_controls == 'left'
|
||||||
local title_ay = self.ay + bg_margin
|
local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
|
||||||
local max_bx = self.title_bx - self.spacing
|
|
||||||
|
|
||||||
-- Playlist position
|
-- Playlist position
|
||||||
if state.has_playlist then
|
if state.has_playlist then
|
||||||
@@ -215,11 +183,13 @@ function TopBar:render()
|
|||||||
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
|
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
|
||||||
.. state.playlist_count
|
.. state.playlist_count
|
||||||
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
|
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
|
||||||
|
local rect_width = round(text_width(text, opts) + padding * 2)
|
||||||
|
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||||
local rect = {
|
local rect = {
|
||||||
ax = title_ax,
|
ax = ax,
|
||||||
ay = title_ay,
|
ay = title_ay,
|
||||||
bx = round(title_ax + text_width(text, opts) + padding * 2),
|
bx = ax + rect_width,
|
||||||
by = self.by - bg_margin,
|
by = self.by - margin,
|
||||||
}
|
}
|
||||||
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
|
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||||
and 1 or config.opacity.playlist_position
|
and 1 or config.opacity.playlist_position
|
||||||
@@ -229,14 +199,14 @@ function TopBar:render()
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
|
ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
|
||||||
title_ax = rect.bx + bg_margin
|
if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
|
||||||
|
|
||||||
-- Click action
|
-- Click action
|
||||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
|
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Skip rendering titles if there's not enough horizontal space
|
-- Skip rendering titles if there's not enough horizontal space
|
||||||
if max_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
|
if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
|
||||||
-- Main title
|
-- Main title
|
||||||
local main_title = self.show_alt_title and self.alt_title or self.main_title
|
local main_title = self.show_alt_title and self.alt_title or self.main_title
|
||||||
if main_title then
|
if main_title then
|
||||||
@@ -247,11 +217,13 @@ function TopBar:render()
|
|||||||
opacity = visibility,
|
opacity = visibility,
|
||||||
border = options.text_border * state.scale,
|
border = options.text_border * state.scale,
|
||||||
border_color = bg,
|
border_color = bg,
|
||||||
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
|
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
|
||||||
}
|
}
|
||||||
local bx = round(math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2))
|
local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
|
||||||
local by = self.by - bg_margin
|
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
|
||||||
local title_rect = {ax = title_ax, ay = title_ay, bx = bx, by = by}
|
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||||
|
local by = self.by - margin
|
||||||
|
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
|
||||||
|
|
||||||
if options.top_bar_alt_title_place == 'toggle' then
|
if options.top_bar_alt_title_place == 'toggle' then
|
||||||
cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
|
cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
|
||||||
@@ -260,7 +232,9 @@ function TopBar:render()
|
|||||||
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
|
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
|
||||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||||
})
|
})
|
||||||
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, main_title, opts)
|
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||||
|
local x = align == 6 and title_rect.bx - padding or ax + padding
|
||||||
|
ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
|
||||||
title_ay = by + spacing
|
title_ay = by + spacing
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -277,12 +251,17 @@ function TopBar:render()
|
|||||||
border_color = bg,
|
border_color = bg,
|
||||||
opacity = visibility,
|
opacity = visibility,
|
||||||
}
|
}
|
||||||
local bx = round(math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2))
|
local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
|
||||||
|
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
|
||||||
|
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||||
|
local bx = ax + rect_width
|
||||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
|
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
|
||||||
ass:rect(title_ax, title_ay, bx, by, {
|
ass:rect(ax, title_ay, bx, by, {
|
||||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||||
})
|
})
|
||||||
ass:txt(title_ax + padding, title_ay + height / 2, 4, self.alt_title, opts)
|
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||||
|
local x = align == 6 and bx - padding or ax + padding
|
||||||
|
ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
|
||||||
title_ay = by + spacing
|
title_ay = by + spacing
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -291,7 +270,8 @@ function TopBar:render()
|
|||||||
local padding_half = round(padding / 2)
|
local padding_half = round(padding / 2)
|
||||||
local font_size = self.font_size * 0.8
|
local font_size = self.font_size * 0.8
|
||||||
local height = font_size * 1.3
|
local height = font_size * 1.3
|
||||||
local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
|
local prefix, postfix = left_aligned and '' or '└ ', left_aligned and ' ┘' or ''
|
||||||
|
local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
|
||||||
local next_chapter = state.chapters[state.current_chapter.index + 1]
|
local next_chapter = state.chapters[state.current_chapter.index + 1]
|
||||||
local chapter_end = next_chapter and next_chapter.time or state.duration or 0
|
local chapter_end = next_chapter and next_chapter.time or state.duration or 0
|
||||||
local remaining_time = ((state.time or 0) - chapter_end) /
|
local remaining_time = ((state.time or 0) - chapter_end) /
|
||||||
@@ -310,26 +290,29 @@ function TopBar:render()
|
|||||||
local remaining_box_width = remaining_width + padding_half * 2
|
local remaining_box_width = remaining_width + padding_half * 2
|
||||||
|
|
||||||
-- Title
|
-- Title
|
||||||
|
local max_bx = title_bx - remaining_box_width - spacing
|
||||||
|
local rect_ideal_width = round(text_width(text, opts) + padding * 2)
|
||||||
|
local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
|
||||||
|
local ax = left_aligned and title_bx - rect_width or title_ax
|
||||||
local rect = {
|
local rect = {
|
||||||
ax = title_ax,
|
ax = ax,
|
||||||
ay = title_ay,
|
ay = title_ay,
|
||||||
bx = round(math.min(
|
bx = ax + rect_width,
|
||||||
max_bx - remaining_box_width - spacing,
|
|
||||||
title_ax + text_width(text, opts) + padding * 2
|
|
||||||
)),
|
|
||||||
by = title_ay + height,
|
by = title_ay + height,
|
||||||
}
|
}
|
||||||
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
|
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
|
||||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||||
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
|
||||||
})
|
})
|
||||||
ass:txt(rect.ax + padding, rect.ay + height / 2, 4, text, opts)
|
local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
|
||||||
|
local x = align == 6 and rect.bx - padding or rect.ax + padding
|
||||||
|
ass:txt(x, rect.ay + height / 2, align, text, opts)
|
||||||
|
|
||||||
-- Click action
|
-- Click action
|
||||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
||||||
|
|
||||||
-- Time
|
-- Time
|
||||||
rect.ax = rect.bx + spacing
|
rect.ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
|
||||||
rect.bx = rect.ax + remaining_box_width
|
rect.bx = rect.ax + remaining_box_width
|
||||||
opts.clip = nil
|
opts.clip = nil
|
||||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||||
|
@@ -85,30 +85,56 @@ end
|
|||||||
-- Tooltip.
|
-- Tooltip.
|
||||||
---@param element Rect
|
---@param element Rect
|
||||||
---@param value string|number
|
---@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)
|
function ass_mt:tooltip(element, value, opts)
|
||||||
if value == '' then return end
|
if value == '' then return end
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
opts.size = opts.size or round(16 * state.scale)
|
opts.size = opts.size or round(16 * state.scale)
|
||||||
opts.border = options.text_border * 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.margin = opts.margin or round(10 * state.scale)
|
||||||
opts.lines = opts.lines or 1
|
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_y = round(opts.size / 6)
|
||||||
local padding_x = round(opts.size / 3)
|
local padding_x = round(opts.size / 3)
|
||||||
local offset = opts.offset or 2
|
local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
|
||||||
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
|
local height = opts.size * opts.lines + 2 * padding_y
|
||||||
local x = element.ax + (element.bx - element.ax) / 2
|
local width_half, height_half = width / 2, height / 2
|
||||||
local y = align_top and element.ay - offset or element.by + offset
|
local margin = opts.margin + Elements:v('window_border', 'size', 0)
|
||||||
local width_half = (opts.width_overwrite or text_width(value, opts)) / 2 + padding_x
|
local align = opts.align or 8
|
||||||
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 x, y = 0, 0 -- center of tooltip
|
||||||
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)
|
-- Flip alignment to other side when not enough space
|
||||||
local by = (align_top and y or y + opts.size * opts.lines + 2 * padding_y)
|
if opts.responsive ~= false then
|
||||||
self:rect(ax, ay, bx, by, {color = bg, opacity = config.opacity.tooltip, radius = state.radius})
|
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
|
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}
|
return {ax = element.ax, ay = ay, bx = element.bx, by = by}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
69
src/uosc/lib/buttons.lua
Normal file
69
src/uosc/lib/buttons.lua
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---@alias ButtonData {icon: string; active?: boolean; badge?: string; command?: string | string[]; tooltip?: string;}
|
||||||
|
---@alias ButtonSubscriber fun(data: ButtonData)
|
||||||
|
|
||||||
|
local buttons = {
|
||||||
|
---@type ButtonData[]
|
||||||
|
data = {},
|
||||||
|
---@type table<string, ButtonSubscriber[]>
|
||||||
|
subscribers = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
---@param callback fun(data: ButtonData)
|
||||||
|
function buttons:subscribe(name, callback)
|
||||||
|
local pool = self.subscribers[name]
|
||||||
|
if not pool then
|
||||||
|
pool = {}
|
||||||
|
self.subscribers[name] = pool
|
||||||
|
end
|
||||||
|
pool[#pool + 1] = callback
|
||||||
|
self:trigger(name)
|
||||||
|
return function() buttons:unsubscribe(name, callback) end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
---@param callback? ButtonSubscriber
|
||||||
|
function buttons:unsubscribe(name, callback)
|
||||||
|
if self.subscribers[name] then
|
||||||
|
if callback == nil then
|
||||||
|
self.subscribers[name] = {}
|
||||||
|
else
|
||||||
|
itable_delete_value(self.subscribers[name], callback)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
function buttons:trigger(name)
|
||||||
|
local pool = self.subscribers[name]
|
||||||
|
local data = self.data[name] or {icon = 'help_center', tooltip = 'Uninitialized button "' .. name .. '"'}
|
||||||
|
if pool then
|
||||||
|
for _, callback in ipairs(pool) do callback(data) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
---@param data ButtonData
|
||||||
|
function buttons:set(name, data)
|
||||||
|
buttons.data[name] = data
|
||||||
|
buttons:trigger(name)
|
||||||
|
request_render()
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.register_script_message('set-button', function(name, data)
|
||||||
|
if type(name) ~= 'string' then
|
||||||
|
msg.error('Invalid set-button message parameter: 1st parameter (name) has to be a string.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(data) ~= 'string' then
|
||||||
|
msg.error('Invalid set-button message parameter: 2nd parameter (data) has to be a string.')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local data = utils.parse_json(data)
|
||||||
|
if type(data) == 'table' and type(data.icon) == 'string' then
|
||||||
|
buttons:set(name, data)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
return buttons
|
@@ -4,13 +4,8 @@ local char_dir = mp.get_script_directory() .. '/char-conv/'
|
|||||||
local data = {}
|
local data = {}
|
||||||
|
|
||||||
local languages = get_languages()
|
local languages = get_languages()
|
||||||
for i = #languages, 1, -1 do
|
for _, lang in ipairs(languages) do
|
||||||
lang = languages[i]
|
|
||||||
if (lang == 'en') then
|
|
||||||
data = {}
|
|
||||||
else
|
|
||||||
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
|
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local romanization = {}
|
local romanization = {}
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
---@param data MenuData
|
---@param data MenuData
|
||||||
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
|
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
|
||||||
function open_command_menu(data, opts)
|
function open_command_menu(data, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local menu
|
||||||
|
|
||||||
local function run_command(command)
|
local function run_command(command)
|
||||||
if type(command) == 'string' then
|
if type(command) == 'string' then
|
||||||
mp.command(command)
|
mp.command(command)
|
||||||
@@ -9,14 +12,21 @@ function open_command_menu(data, opts)
|
|||||||
mp.commandv(unpack(command))
|
mp.commandv(unpack(command))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
---@type MenuOptions
|
|
||||||
local menu_opts = {}
|
local function callback(event)
|
||||||
if opts then
|
if type(menu.root.callback) == 'table' then
|
||||||
menu_opts.mouse_nav = opts.mouse_nav
|
---@diagnostic disable-next-line: deprecated
|
||||||
if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end
|
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)
|
end
|
||||||
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
|
return menu
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,7 +39,8 @@ function toggle_menu_with_items(opts)
|
|||||||
end
|
end
|
||||||
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)
|
function create_self_updating_menu_opener(opts)
|
||||||
return function()
|
return function()
|
||||||
if Menu:is_open(opts.type) then
|
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)
|
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
|
-- 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.
|
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
||||||
menu = Menu:open(
|
menu = Menu:open({
|
||||||
{
|
|
||||||
type = opts.type,
|
type = opts.type,
|
||||||
title = opts.title,
|
title = opts.title,
|
||||||
items = initial_items,
|
items = initial_items,
|
||||||
|
actions = actions,
|
||||||
selected_index = selected_index,
|
selected_index = selected_index,
|
||||||
on_paste = opts.on_paste,
|
on_move = opts.on_move and 'callback' or nil,
|
||||||
},
|
on_close = 'callback',
|
||||||
opts.on_select, {
|
}, function(event)
|
||||||
on_open = function()
|
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
|
||||||
|
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)
|
mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
|
||||||
if opts.active_prop then
|
if opts.active_prop then
|
||||||
mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
|
mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
|
||||||
end
|
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,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -95,20 +164,23 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
local function serialize_tracklist(tracklist)
|
local function serialize_tracklist(tracklist)
|
||||||
local items = {}
|
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
|
if load_command then
|
||||||
items[#items + 1] = {
|
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
|
end
|
||||||
if #items > 0 then
|
if #items > 0 then
|
||||||
items[#items].separator = true
|
items[#items].separator = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local track_prop_index = tonumber(mp.get_property(track_prop))
|
||||||
local first_item_index = #items + 1
|
local first_item_index = #items + 1
|
||||||
local active_index = nil
|
local active_index = nil
|
||||||
local disabled_item = nil
|
local disabled_item = nil
|
||||||
@@ -126,9 +198,10 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
for _, track in ipairs(tracklist) do
|
for _, track in ipairs(tracklist) do
|
||||||
if track.type == track_type then
|
if track.type == track_type then
|
||||||
local hint_values = {}
|
local hint_values = {}
|
||||||
|
local track_selected = track.selected and track.id == track_prop_index
|
||||||
local function h(value) hint_values[#hint_values + 1] = value end
|
local function h(value) hint_values[#hint_values + 1] = value end
|
||||||
|
|
||||||
if track.lang then h(track.lang:upper()) end
|
if track.lang then h(track.lang) end
|
||||||
if track['demux-h'] then
|
if track['demux-h'] then
|
||||||
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
||||||
end
|
end
|
||||||
@@ -148,10 +221,10 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
title = (track.title and track.title or t('Track %s', track.id)),
|
title = (track.title and track.title or t('Track %s', track.id)),
|
||||||
hint = table.concat(hint_values, ', '),
|
hint = table.concat(hint_values, ', '),
|
||||||
value = track.id,
|
value = track.id,
|
||||||
active = track.selected,
|
active = track_selected,
|
||||||
}
|
}
|
||||||
|
|
||||||
if track.selected then
|
if track_selected then
|
||||||
if disabled_item then disabled_item.active = false end
|
if disabled_item then disabled_item.active = false end
|
||||||
active_index = #items
|
active_index = #items
|
||||||
end
|
end
|
||||||
@@ -161,16 +234,15 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
return items, active_index or first_item_index
|
return items, active_index or first_item_index
|
||||||
end
|
end
|
||||||
|
|
||||||
local function handle_select(value)
|
---@param event MenuEventActivate
|
||||||
if value == '{download}' then
|
local function handle_activate(event)
|
||||||
mp.command(download_command)
|
if event.value == '{load}' then
|
||||||
elseif value == '{load}' then
|
mp.command(event.action == 'download' and download_command or load_command)
|
||||||
mp.command(load_command)
|
|
||||||
else
|
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 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')
|
mp.commandv('set', 'sub-visibility', 'yes')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -181,132 +253,40 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
|||||||
type = track_type,
|
type = track_type,
|
||||||
list_prop = 'track-list',
|
list_prop = 'track-list',
|
||||||
serializer = serialize_tracklist,
|
serializer = serialize_tracklist,
|
||||||
on_select = handle_select,
|
on_activate = handle_activate,
|
||||||
on_paste = function(path) load_track(track_type, path) end,
|
on_paste = function(event) load_track(track_type, event.value) end,
|
||||||
})
|
})
|
||||||
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`.
|
-- Opens a file navigation menu with items inside `directory_path`.
|
||||||
---@param directory_path string
|
---@param directory_path string
|
||||||
---@param handle_select fun(path: string, mods: Modifiers): nil
|
---@param handle_activate fun(event: MenuEventActivate)
|
||||||
---@param opts NavigationMenuOptions
|
---@param opts NavigationMenuOptions
|
||||||
function open_file_navigation_menu(directory_path, handle_select, opts)
|
function open_file_navigation_menu(directory_path, handle_activate, opts)
|
||||||
directory = serialize_path(normalize_path(directory_path))
|
if directory_path == '{drives}' then
|
||||||
opts = opts or {}
|
if state.platform ~= 'windows' then directory_path = '/' end
|
||||||
|
|
||||||
if not directory then
|
|
||||||
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if not files or not directories then return 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
|
else
|
||||||
items[#items + 1] = {title = '..', hint = t('parent dir'), value = directory.dirname, separator = true}
|
directory_path = normalize_path(mp.command_native({'expand-path', directory_path}))
|
||||||
end
|
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
|
|
||||||
end
|
|
||||||
|
|
||||||
if opts.selected_path == item.value then selected_index = index end
|
|
||||||
end
|
|
||||||
|
|
||||||
---@type MenuCallback
|
|
||||||
local function open_path(path, meta)
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local info, error = utils.file_info(path)
|
|
||||||
|
|
||||||
if not info then
|
|
||||||
msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if info.is_dir and not 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)
|
|
||||||
else
|
|
||||||
handle_select(path, meta.modifiers)
|
|
||||||
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 {}
|
opts = opts or {}
|
||||||
|
---@type string|nil
|
||||||
|
local current_directory = nil
|
||||||
|
---@type Menu
|
||||||
|
local menu
|
||||||
|
---@type string | nil
|
||||||
|
local back_path
|
||||||
|
local separator = path_separator(directory_path)
|
||||||
|
|
||||||
|
---@param path string Can be path to a directory, or special string `'{drives}'` to get windows drives items.
|
||||||
|
---@param selected_path? string Marks item with this path as active.
|
||||||
|
---@return MenuStackValue[] menu_items
|
||||||
|
---@return number selected_index
|
||||||
|
---@return string|nil error
|
||||||
|
local function serialize_items(path, selected_path)
|
||||||
|
if path == '{drives}' then
|
||||||
local process = mp.command_native({
|
local process = mp.command_native({
|
||||||
name = 'subprocess',
|
name = 'subprocess',
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
@@ -323,24 +303,162 @@ function open_drives_menu(handle_select, opts)
|
|||||||
items[#items + 1] = {
|
items[#items + 1] = {
|
||||||
title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
|
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
|
if selected_path == drive_path then selected_index = #items end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
msg.error(process.stderr)
|
return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr)
|
||||||
|
end
|
||||||
|
return items, selected_index
|
||||||
end
|
end
|
||||||
|
|
||||||
return Menu:open(
|
local serialized = serialize_path(path)
|
||||||
{type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index},
|
if not serialized then
|
||||||
handle_select
|
return {}, 0, 'Couldn\'t serialize path "' .. path .. '.'
|
||||||
)
|
end
|
||||||
|
local files, directories, error = read_directory(serialized.path, {
|
||||||
|
types = opts.allowed_types,
|
||||||
|
hidden = options.show_hidden_files,
|
||||||
|
})
|
||||||
|
if error then
|
||||||
|
return {}, 1, error
|
||||||
|
end
|
||||||
|
local is_root = not serialized.dirname
|
||||||
|
|
||||||
|
if not files or not directories then return {}, 0 end
|
||||||
|
|
||||||
|
sort_strings(directories)
|
||||||
|
sort_strings(files)
|
||||||
|
|
||||||
|
-- Pre-populate items with parent directory selector if not at root
|
||||||
|
-- Each item value is a serialized path table it points to.
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
if is_root then
|
||||||
|
if state.platform == 'windows' then
|
||||||
|
items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true}
|
||||||
|
end
|
||||||
|
|
||||||
|
back_path = items[#items] and items[#items].value
|
||||||
|
local selected_index = #items + 1
|
||||||
|
|
||||||
|
for _, dir in ipairs(directories) do
|
||||||
|
items[#items + 1] = {title = dir, 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.is_to_parent then
|
||||||
|
if opts.active_path == item.value then
|
||||||
|
item.active = true
|
||||||
|
if not selected_path then selected_index = index end
|
||||||
|
end
|
||||||
|
|
||||||
|
if selected_path == item.value then selected_index = index end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return items, selected_index
|
||||||
|
end
|
||||||
|
|
||||||
|
local menu_data = {
|
||||||
|
type = opts.type,
|
||||||
|
title = opts.title or '',
|
||||||
|
items = {},
|
||||||
|
on_close = opts.on_close and 'callback' or nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@param path string
|
||||||
|
local function open_directory(path)
|
||||||
|
local items, selected_index, error = serialize_items(path, current_directory)
|
||||||
|
if error then
|
||||||
|
msg.error(error)
|
||||||
|
items = {{title = 'Something went wrong. See console for errors.', selectable = false, muted = true}}
|
||||||
|
end
|
||||||
|
|
||||||
|
local title = opts.title
|
||||||
|
if not title then
|
||||||
|
if path == '{drives}' then
|
||||||
|
title = 'Drives'
|
||||||
|
else
|
||||||
|
local serialized = serialize_path(path)
|
||||||
|
title = serialized and serialized.basename .. separator or '??'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
current_directory = path
|
||||||
|
menu_data.title = title
|
||||||
|
menu_data.items = items
|
||||||
|
menu:search_cancel()
|
||||||
|
menu:update(menu_data)
|
||||||
|
menu:select_index(selected_index)
|
||||||
|
menu:scroll_to_index(selected_index, nil, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close()
|
||||||
|
menu:close()
|
||||||
|
if opts.on_close then opts.on_close() end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param event MenuEventActivate
|
||||||
|
local function activate(event)
|
||||||
|
local path = event.value
|
||||||
|
local is_drives = path == '{drives}'
|
||||||
|
|
||||||
|
if is_drives then
|
||||||
|
open_directory(path)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local info, error = utils.file_info(path)
|
||||||
|
|
||||||
|
if not info then
|
||||||
|
msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if info.is_dir and event.modifiers == '' then
|
||||||
|
open_directory(path)
|
||||||
|
else
|
||||||
|
handle_activate(event)
|
||||||
|
if not opts.keep_open then close() end
|
||||||
|
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
|
||||||
|
end)
|
||||||
|
|
||||||
|
open_directory(directory_path)
|
||||||
|
|
||||||
|
return menu
|
||||||
end
|
end
|
||||||
|
|
||||||
-- On demand menu items loading
|
-- On demand menu items loading
|
||||||
do
|
do
|
||||||
local items = nil
|
---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil
|
||||||
function get_menu_items()
|
local all_user_bindings = nil
|
||||||
if items then return items end
|
---@type MenuStackItem[]|nil
|
||||||
|
local menu_items = nil
|
||||||
|
|
||||||
|
local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end
|
||||||
|
|
||||||
|
-- Returns all relevant bindings from `input.conf`, even if they are overwritten
|
||||||
|
-- (same key bound to something else later) or have no keys (uosc menu items).
|
||||||
|
function get_all_user_bindings()
|
||||||
|
if all_user_bindings then return all_user_bindings end
|
||||||
|
all_user_bindings = {}
|
||||||
|
|
||||||
local input_conf_property = mp.get_property_native('input-conf')
|
local input_conf_property = mp.get_property_native('input-conf')
|
||||||
local input_conf_iterator
|
local input_conf_iterator
|
||||||
@@ -359,23 +477,53 @@ do
|
|||||||
|
|
||||||
-- File doesn't exist
|
-- File doesn't exist
|
||||||
if not input_conf_meta or not input_conf_meta.is_file then
|
if not input_conf_meta or not input_conf_meta.is_file then
|
||||||
items = create_default_menu_items()
|
menu_items = create_default_menu_items()
|
||||||
return items
|
return menu_items, all_user_bindings
|
||||||
end
|
end
|
||||||
|
|
||||||
input_conf_iterator = io.lines(input_conf_path)
|
input_conf_iterator = io.lines(input_conf_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
for line in input_conf_iterator do
|
||||||
|
local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$')
|
||||||
|
local is_commented_out = key and key:sub(1, 1) == '#'
|
||||||
|
|
||||||
|
if comment and #comment > 0 then comment = comment:sub(2) end
|
||||||
|
if command then command = trim(command) end
|
||||||
|
|
||||||
|
local is_menu_item = comment and is_uosc_menu_comment(comment)
|
||||||
|
|
||||||
|
if key
|
||||||
|
-- Filter out stuff like `#F2`, which is clearly intended to be disabled
|
||||||
|
and not (is_commented_out and #key > 1)
|
||||||
|
-- Filter out comments that are not uosc menu items
|
||||||
|
and (not is_commented_out or is_menu_item) then
|
||||||
|
all_user_bindings[#all_user_bindings + 1] = {
|
||||||
|
key = key,
|
||||||
|
cmd = command,
|
||||||
|
comment = comment or '',
|
||||||
|
is_menu_item = is_menu_item,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return all_user_bindings
|
||||||
|
end
|
||||||
|
|
||||||
|
function get_menu_items()
|
||||||
|
if menu_items then return menu_items end
|
||||||
|
|
||||||
|
local all_user_bindings = get_all_user_bindings()
|
||||||
local main_menu = {items = {}, items_by_command = {}}
|
local main_menu = {items = {}, items_by_command = {}}
|
||||||
local by_id = {}
|
local by_id = {}
|
||||||
|
|
||||||
for line in input_conf_iterator do
|
for _, bind in ipairs(all_user_bindings) do
|
||||||
local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
|
local key, command, comment = bind.key, bind.cmd, bind.comment
|
||||||
local title = ''
|
local title = ''
|
||||||
|
|
||||||
if comment then
|
if comment then
|
||||||
local comments = split(comment, '#')
|
local comments = split(comment, '#')
|
||||||
local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end)
|
local titles = itable_filter(comments, is_uosc_menu_comment)
|
||||||
if titles and #titles > 0 then
|
if titles and #titles > 0 then
|
||||||
title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
|
title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
|
||||||
end
|
end
|
||||||
@@ -399,7 +547,6 @@ do
|
|||||||
|
|
||||||
target_menu = by_id[submenu_id]
|
target_menu = by_id[submenu_id]
|
||||||
else
|
else
|
||||||
if command == 'ignore' then break end
|
|
||||||
-- If command is already in menu, just append the key to it
|
-- If command is already in menu, just append the key to it
|
||||||
if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
|
if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
|
||||||
local hint = target_menu.items_by_command[command].hint
|
local hint = target_menu.items_by_command[command].hint
|
||||||
@@ -409,7 +556,7 @@ do
|
|||||||
if title_part:sub(1, 3) == '---' then
|
if title_part:sub(1, 3) == '---' then
|
||||||
local last_item = target_menu.items[#target_menu.items]
|
local last_item = target_menu.items[#target_menu.items]
|
||||||
if last_item then last_item.separator = true end
|
if last_item then last_item.separator = true end
|
||||||
else
|
elseif command ~= 'ignore' then
|
||||||
local item = {
|
local item = {
|
||||||
title = title_part,
|
title = title_part,
|
||||||
hint = not is_dummy and key or nil,
|
hint = not is_dummy and key or nil,
|
||||||
@@ -430,20 +577,30 @@ do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
|
menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
|
||||||
return items
|
return menu_items
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Adapted from `stats.lua`
|
-- Adapted from `stats.lua`
|
||||||
function get_keybinds_items()
|
function get_keybinds_items()
|
||||||
local items = {}
|
local items = {}
|
||||||
local active = find_active_keybindings()
|
-- uosc and mpv-menu-plugin binds with no keys
|
||||||
|
local no_key_menu_binds = itable_filter(
|
||||||
|
get_all_user_bindings(),
|
||||||
|
function(b) return b.is_menu_item and b.cmd and b.cmd ~= '' and (b.key == '#' or b.key == '_') end
|
||||||
|
)
|
||||||
|
local binds_dump = itable_join(find_active_keybindings(), no_key_menu_binds)
|
||||||
|
local ids = {}
|
||||||
|
|
||||||
-- Convert to menu items
|
-- Convert to menu items
|
||||||
for _, bind in pairs(active) do
|
for _, bind in pairs(binds_dump) do
|
||||||
|
local id = bind.key .. '<>' .. bind.cmd
|
||||||
|
if not ids[id] then
|
||||||
|
ids[id] = true
|
||||||
items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
|
items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Sort
|
-- Sort
|
||||||
table.sort(items, function(a, b) return a.title < b.title end)
|
table.sort(items, function(a, b) return a.title < b.title end)
|
||||||
@@ -467,14 +624,17 @@ function open_stream_quality_menu()
|
|||||||
|
|
||||||
local ytdl_format = mp.get_property_native('ytdl-format')
|
local ytdl_format = mp.get_property_native('ytdl-format')
|
||||||
local items = {}
|
local items = {}
|
||||||
|
---@type Menu
|
||||||
|
local menu
|
||||||
|
|
||||||
for _, height in ipairs(config.stream_quality_options) do
|
for _, height in ipairs(config.stream_quality_options) do
|
||||||
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
|
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
|
||||||
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
|
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
|
||||||
end
|
end
|
||||||
|
|
||||||
Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
|
menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event)
|
||||||
mp.set_property('ytdl-format', format)
|
if event.type == 'activate' then
|
||||||
|
mp.set_property('ytdl-format', event.value)
|
||||||
|
|
||||||
-- Reload the video to apply new format
|
-- Reload the video to apply new format
|
||||||
-- This is taken from https://github.com/jgreco/mpv-youtube-quality
|
-- This is taken from https://github.com/jgreco/mpv-youtube-quality
|
||||||
@@ -496,6 +656,9 @@ function open_stream_quality_menu()
|
|||||||
end
|
end
|
||||||
mp.register_event('file-loaded', seeker)
|
mp.register_event('file-loaded', seeker)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if event.modifiers ~= 'shift' then menu:close() end
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -505,15 +668,14 @@ function open_open_file_menu()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@type Menu | nil
|
||||||
|
local menu
|
||||||
local directory
|
local directory
|
||||||
local active_file
|
local active_file
|
||||||
|
|
||||||
if state.path == nil or is_protocol(state.path) then
|
if state.path == nil or is_protocol(state.path) then
|
||||||
local serialized = serialize_path(get_default_directory())
|
directory = options.default_directory
|
||||||
if serialized then
|
|
||||||
directory = serialized.path
|
|
||||||
active_file = nil
|
active_file = nil
|
||||||
end
|
|
||||||
else
|
else
|
||||||
local serialized = serialize_path(state.path)
|
local serialized = serialize_path(state.path)
|
||||||
if serialized then
|
if serialized then
|
||||||
@@ -528,7 +690,6 @@ function open_open_file_menu()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Update active file in directory navigation menu
|
-- Update active file in directory navigation menu
|
||||||
local menu = nil
|
|
||||||
local function handle_file_loaded()
|
local function handle_file_loaded()
|
||||||
if menu and menu:is_alive() then
|
if menu and menu:is_alive() then
|
||||||
menu:activate_one_value(normalize_path(mp.get_property_native('path')))
|
menu:activate_one_value(normalize_path(mp.get_property_native('path')))
|
||||||
@@ -537,12 +698,14 @@ function open_open_file_menu()
|
|||||||
|
|
||||||
menu = open_file_navigation_menu(
|
menu = open_file_navigation_menu(
|
||||||
directory,
|
directory,
|
||||||
function(path, mods)
|
function(event)
|
||||||
if mods.ctrl then
|
if not menu then return end
|
||||||
mp.commandv('loadfile', path, 'append')
|
local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
|
||||||
else
|
if itable_has({'ctrl', 'ctrl+shift'}, event.modifiers) then
|
||||||
mp.commandv('loadfile', path)
|
mp.commandv(command, event.value, 'append')
|
||||||
Menu:close()
|
elseif event.modifiers == '' then
|
||||||
|
mp.commandv(command, event.value)
|
||||||
|
menu:close()
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
{
|
{
|
||||||
@@ -550,10 +713,10 @@ function open_open_file_menu()
|
|||||||
allowed_types = config.types.media,
|
allowed_types = config.types.media,
|
||||||
active_path = active_file,
|
active_path = active_file,
|
||||||
keep_open = true,
|
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,
|
on_close = function() mp.unregister_event(handle_file_loaded) end,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if menu then mp.register_event('file-loaded', handle_file_loaded) end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
|
---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
|
||||||
@@ -581,12 +744,14 @@ function create_track_loader_menu_opener(opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not path then
|
if not path then
|
||||||
path = get_default_directory()
|
path = options.default_directory
|
||||||
end
|
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,
|
type = menu_type, title = title, allowed_types = opts.allowed_types,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -671,7 +836,11 @@ function open_subtitle_downloader()
|
|||||||
type = menu_type .. '-result',
|
type = menu_type .. '-result',
|
||||||
search_style = 'disabled',
|
search_style = 'disabled',
|
||||||
items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
|
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, {
|
local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
|
||||||
'--file-id', tostring(data.id),
|
'--file-id', tostring(data.id),
|
||||||
@@ -822,10 +991,16 @@ function open_subtitle_downloader()
|
|||||||
title = t('enter query'),
|
title = t('enter query'),
|
||||||
items = initial_items,
|
items = initial_items,
|
||||||
search_style = 'palette',
|
search_style = 'palette',
|
||||||
on_search = handle_search,
|
on_search = 'callback',
|
||||||
search_debounce = 'submit',
|
search_debounce = 'submit',
|
||||||
search_suggestion = search_suggestion,
|
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
|
end
|
||||||
|
@@ -17,6 +17,11 @@ function serialize_rgba(rgba)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Trim any white space from the start and end of the string.
|
||||||
|
---@param str string
|
||||||
|
---@return string
|
||||||
|
function trim(str) return str:match('^%s*(.-)%s*$') end
|
||||||
|
|
||||||
-- Trim any `char` from the end of the string.
|
-- Trim any `char` from the end of the string.
|
||||||
---@param str string
|
---@param str string
|
||||||
---@param char string
|
---@param char string
|
||||||
@@ -217,6 +222,19 @@ function table_assign_props(target, source, props)
|
|||||||
return target
|
return target
|
||||||
end
|
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 :(
|
-- `table_assign({}, input)` without loosing types :(
|
||||||
---@generic T: table<any, any>
|
---@generic T: table<any, any>
|
||||||
---@param input T
|
---@param input T
|
||||||
|
@@ -4,6 +4,8 @@
|
|||||||
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
|
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
|
||||||
---@alias Circle {point: Point, r: number, window_drag?: boolean}
|
---@alias Circle {point: Point, r: number, window_drag?: boolean}
|
||||||
---@alias Hitbox Rect|Circle
|
---@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
|
--- In place sorting of filenames
|
||||||
---@param filenames string[]
|
---@param filenames string[]
|
||||||
@@ -398,9 +400,40 @@ function has_any_extension(path, extensions)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return string
|
---@param key string
|
||||||
function get_default_directory()
|
---@param modifiers? string
|
||||||
return mp.command_native({'expand-path', options.default_directory})
|
---@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
|
||||||
|
---@return boolean executed `true` if command was executed.
|
||||||
|
function execute_command(command)
|
||||||
|
local command_type = type(command)
|
||||||
|
if command_type == 'string' then
|
||||||
|
mp.command(command)
|
||||||
|
return true
|
||||||
|
elseif command_type == 'table' and #command > 0 then
|
||||||
|
mp.command_native(command)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Serializes path into its semantic parts.
|
-- Serializes path into its semantic parts.
|
||||||
@@ -427,19 +460,18 @@ end
|
|||||||
-- Reads items in directory and splits it into directories and files tables.
|
-- Reads items in directory and splits it into directories and files tables.
|
||||||
---@param path string
|
---@param path string
|
||||||
---@param opts? {types?: string[], hidden?: boolean}
|
---@param opts? {types?: string[], hidden?: boolean}
|
||||||
---@return string[]|nil files
|
---@return string[] files
|
||||||
---@return string[]|nil directories
|
---@return string[] directories
|
||||||
|
---@return string|nil error
|
||||||
function read_directory(path, opts)
|
function read_directory(path, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local items, error = utils.readdir(path, 'all')
|
local items, error = utils.readdir(path, 'all')
|
||||||
|
local files, directories = {}, {}
|
||||||
|
|
||||||
if not items then
|
if not items then
|
||||||
msg.error('Reading files from "' .. path .. '" failed: ' .. error)
|
return files, directories, 'Reading directory "' .. path .. '" failed. Error: ' .. utils.to_string(error)
|
||||||
return nil, nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local files, directories = {}, {}
|
|
||||||
|
|
||||||
for _, item in ipairs(items) do
|
for _, item in ipairs(items) do
|
||||||
if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
|
if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
|
||||||
local info = utils.file_info(join_path(path, item))
|
local info = utils.file_info(join_path(path, item))
|
||||||
@@ -467,8 +499,8 @@ function get_adjacent_files(file_path, opts)
|
|||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local current_meta = serialize_path(file_path)
|
local current_meta = serialize_path(file_path)
|
||||||
if not current_meta then return end
|
if not current_meta then return end
|
||||||
local files = read_directory(current_meta.dirname, {hidden = opts.hidden})
|
local files, _dirs, error = read_directory(current_meta.dirname, {hidden = opts.hidden})
|
||||||
if not files then return end
|
if error then msg.error(error) return end
|
||||||
sort_strings(files)
|
sort_strings(files)
|
||||||
local current_file_index
|
local current_file_index
|
||||||
local paths = {}
|
local paths = {}
|
||||||
@@ -782,18 +814,20 @@ end
|
|||||||
---@return {[string]: table}|table
|
---@return {[string]: table}|table
|
||||||
function find_active_keybindings(key)
|
function find_active_keybindings(key)
|
||||||
local bindings = mp.get_property_native('input-bindings', {})
|
local bindings = mp.get_property_native('input-bindings', {})
|
||||||
local active = {} -- map: key-name -> bind-info
|
local active_map = {} -- map: key-name -> bind-info
|
||||||
|
local active_table = {}
|
||||||
for _, bind in pairs(bindings) do
|
for _, bind in pairs(bindings) do
|
||||||
if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
|
if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
|
||||||
not active[bind.key]
|
not active_map[bind.key]
|
||||||
or (active[bind.key].is_weak and not bind.is_weak)
|
or (active_map[bind.key].is_weak and not bind.is_weak)
|
||||||
or (bind.is_weak == active[bind.key].is_weak and bind.priority > active[bind.key].priority)
|
or (bind.is_weak == active_map[bind.key].is_weak and bind.priority > active_map[bind.key].priority)
|
||||||
)
|
)
|
||||||
then
|
then
|
||||||
active[bind.key] = bind
|
active_table[#active_table + 1] = bind
|
||||||
|
active_map[bind.key] = bind
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return not key and active or active[key]
|
return key and active_map[key] or active_table
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param type 'sub'|'audio'|'video'
|
---@param type 'sub'|'audio'|'video'
|
||||||
|
@@ -51,7 +51,7 @@ defaults = {
|
|||||||
top_bar = 'no-border',
|
top_bar = 'no-border',
|
||||||
top_bar_size = 40,
|
top_bar_size = 40,
|
||||||
top_bar_persistency = '',
|
top_bar_persistency = '',
|
||||||
top_bar_controls = true,
|
top_bar_controls = 'right',
|
||||||
top_bar_title = 'yes',
|
top_bar_title = 'yes',
|
||||||
top_bar_alt_title = '',
|
top_bar_alt_title = '',
|
||||||
top_bar_alt_title_place = 'below',
|
top_bar_alt_title_place = 'below',
|
||||||
@@ -92,6 +92,7 @@ defaults = {
|
|||||||
'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv',
|
'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv',
|
||||||
image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp',
|
image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp',
|
||||||
subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
|
subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
|
||||||
|
playlist_types = 'm3u,m3u8,pls,url,cue',
|
||||||
default_directory = '~/',
|
default_directory = '~/',
|
||||||
show_hidden_files = false,
|
show_hidden_files = false,
|
||||||
use_trash = false,
|
use_trash = false,
|
||||||
@@ -126,6 +127,9 @@ if options.total_time and options.destination_time == 'playtime-remaining' then
|
|||||||
elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then
|
elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then
|
||||||
options.destination_time = 'playtime-remaining'
|
options.destination_time = 'playtime-remaining'
|
||||||
end
|
end
|
||||||
|
if not itable_index_of({'left', 'right'}, options.top_bar_controls) then
|
||||||
|
options.top_bar_controls = options.top_bar_controls == 'yes' and 'right' or nil
|
||||||
|
end
|
||||||
-- Ensure required environment configuration
|
-- Ensure required environment configuration
|
||||||
if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
|
if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
|
||||||
|
|
||||||
@@ -184,7 +188,11 @@ config = {
|
|||||||
audio = comma_split(options.audio_types),
|
audio = comma_split(options.audio_types),
|
||||||
image = comma_split(options.image_types),
|
image = comma_split(options.image_types),
|
||||||
subtitle = comma_split(options.subtitle_types),
|
subtitle = comma_split(options.subtitle_types),
|
||||||
media = comma_split(options.video_types .. ',' .. options.audio_types .. ',' .. options.image_types),
|
playlist = comma_split(options.playlist_types),
|
||||||
|
media = comma_split(options.video_types
|
||||||
|
.. ',' .. options.audio_types
|
||||||
|
.. ',' .. options.image_types
|
||||||
|
.. ',' .. options.playlist_types),
|
||||||
autoload = (function()
|
autoload = (function()
|
||||||
---@type string[]
|
---@type string[]
|
||||||
local option_values = {}
|
local option_values = {}
|
||||||
@@ -337,11 +345,13 @@ state = {
|
|||||||
alt_title = nil,
|
alt_title = nil,
|
||||||
time = nil, -- current media playback time
|
time = nil, -- current media playback time
|
||||||
speed = 1,
|
speed = 1,
|
||||||
|
---@type number|nil
|
||||||
duration = nil, -- current media duration
|
duration = nil, -- current media duration
|
||||||
time_human = nil, -- current playback time in human format
|
time_human = nil, -- current playback time in human format
|
||||||
destination_time_human = nil, -- depends on options.destination_time
|
destination_time_human = nil, -- depends on options.destination_time
|
||||||
pause = mp.get_property_native('pause'),
|
pause = mp.get_property_native('pause'),
|
||||||
chapters = {},
|
chapters = {},
|
||||||
|
---@type {index: number; title: string}|nil
|
||||||
current_chapter = nil,
|
current_chapter = nil,
|
||||||
chapter_ranges = {},
|
chapter_ranges = {},
|
||||||
border = mp.get_property_native('border'),
|
border = mp.get_property_native('border'),
|
||||||
@@ -373,6 +383,7 @@ state = {
|
|||||||
cache = nil,
|
cache = nil,
|
||||||
cache_buffering = 100,
|
cache_buffering = 100,
|
||||||
cache_underrun = false,
|
cache_underrun = false,
|
||||||
|
cache_duration = nil,
|
||||||
core_idle = false,
|
core_idle = false,
|
||||||
eof_reached = false,
|
eof_reached = false,
|
||||||
render_delay = config.render_delay,
|
render_delay = config.render_delay,
|
||||||
@@ -386,6 +397,7 @@ state = {
|
|||||||
scale = 1,
|
scale = 1,
|
||||||
radius = 0,
|
radius = 0,
|
||||||
}
|
}
|
||||||
|
buttons = require('lib/buttons')
|
||||||
thumbnail = {width = 0, height = 0, disabled = false}
|
thumbnail = {width = 0, height = 0, disabled = false}
|
||||||
external = {} -- Properties set by external scripts
|
external = {} -- Properties set by external scripts
|
||||||
key_binding_overwrites = {} -- Table of key_binding:mpv_command
|
key_binding_overwrites = {} -- Table of key_binding:mpv_command
|
||||||
@@ -542,12 +554,16 @@ function load_file_index_in_current_directory(index)
|
|||||||
|
|
||||||
local serialized = serialize_path(state.path)
|
local serialized = serialize_path(state.path)
|
||||||
if serialized and serialized.dirname then
|
if serialized and serialized.dirname then
|
||||||
local files = read_directory(serialized.dirname, {
|
local files, _dirs, error = read_directory(serialized.dirname, {
|
||||||
types = config.types.autoload,
|
types = config.types.autoload,
|
||||||
hidden = options.show_hidden_files,
|
hidden = options.show_hidden_files,
|
||||||
})
|
})
|
||||||
|
|
||||||
if not files then return end
|
if error then
|
||||||
|
msg.error(error)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
sort_strings(files)
|
sort_strings(files)
|
||||||
if index < 0 then index = #files + index + 1 end
|
if index < 0 then index = #files + index + 1 end
|
||||||
|
|
||||||
@@ -570,11 +586,18 @@ function observe_display_fps(name, fps)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function select_current_chapter()
|
function select_current_chapter()
|
||||||
|
local current_chapter_index = state.current_chapter and state.current_chapter.index
|
||||||
local current_chapter
|
local current_chapter
|
||||||
if state.time and state.chapters then
|
if state.time and state.chapters then
|
||||||
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
|
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
|
||||||
end
|
end
|
||||||
|
local new_chapter_index = current_chapter and current_chapter.index
|
||||||
|
if current_chapter_index ~= new_chapter_index then
|
||||||
set_state('current_chapter', current_chapter)
|
set_state('current_chapter', current_chapter)
|
||||||
|
if itable_has(config.top_bar_flash_on, 'chapter') then
|
||||||
|
Elements:flash({'top_bar'})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[ STATE HOOKS ]]
|
--[[ STATE HOOKS ]]
|
||||||
@@ -766,6 +789,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
|||||||
if cache_state then
|
if cache_state then
|
||||||
cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
|
cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
|
||||||
set_state('cache_underrun', cache_state['underrun'])
|
set_state('cache_underrun', cache_state['underrun'])
|
||||||
|
set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
|
||||||
else
|
else
|
||||||
cached_ranges = {}
|
cached_ranges = {}
|
||||||
end
|
end
|
||||||
@@ -773,6 +797,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
|||||||
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
|
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
|
||||||
(state.cache == 'auto' and state.is_stream))) then
|
(state.cache == 'auto' and state.is_stream))) then
|
||||||
if state.uncached_ranges then set_state('uncached_ranges', nil) end
|
if state.uncached_ranges then set_state('uncached_ranges', nil) end
|
||||||
|
set_state('cache_duration', nil)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -886,11 +911,12 @@ bind_command('playlist', create_self_updating_menu_opener({
|
|||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
end,
|
end,
|
||||||
on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end,
|
on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
|
||||||
on_move_item = function(from, to)
|
on_move = function(event)
|
||||||
mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1))
|
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,
|
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({
|
bind_command('chapters', create_self_updating_menu_opener({
|
||||||
title = t('Chapters'),
|
title = t('Chapters'),
|
||||||
@@ -910,7 +936,7 @@ bind_command('chapters', create_self_updating_menu_opener({
|
|||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
end,
|
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({
|
bind_command('editions', create_self_updating_menu_opener({
|
||||||
title = t('Editions'),
|
title = t('Editions'),
|
||||||
@@ -930,7 +956,7 @@ bind_command('editions', create_self_updating_menu_opener({
|
|||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
end,
|
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()
|
bind_command('show-in-directory', function()
|
||||||
-- Ignore URLs
|
-- Ignore URLs
|
||||||
@@ -1011,7 +1037,7 @@ bind_command('audio-device', create_self_updating_menu_opener({
|
|||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
end,
|
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()
|
bind_command('open-config-directory', function()
|
||||||
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
|
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
|
||||||
@@ -1060,6 +1086,17 @@ mp.register_script_message('update-menu', function(json)
|
|||||||
if menu then menu:update(data) end
|
if menu then menu:update(data) end
|
||||||
end
|
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)
|
mp.register_script_message('close-menu', function(type)
|
||||||
if Menu:is_open(type) then Menu:close() end
|
if Menu:is_open(type) then Menu:close() end
|
||||||
end)
|
end)
|
||||||
|
Reference in New Issue
Block a user