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.
|
||||
- Just hovering video with no UI widget below cursor: your configured wheel bindings from `input.conf`.
|
||||
- Right click on volume or speed elements to reset them.
|
||||
- Transform chapters into timeline ranges (the red portion of the timeline in the preview).
|
||||
- And a lot of useful options and commands to bind keys to.
|
||||
- Transforming chapters into timeline ranges (the red portion of the timeline in the preview).
|
||||
- A lot of useful options and commands to bind keys to.
|
||||
- [API for 3rd party scripts](https://github.com/tomasklaen/uosc/wiki) to extend, or use uosc to render their menus.
|
||||
|
||||
[Changelog](https://github.com/tomasklaen/uosc/releases).
|
||||
|
||||
@@ -115,18 +116,18 @@ To change the font, **uosc** respects the mpv's `osd-font` configuration.
|
||||
These bindings are active when any **uosc** menu is open (main menu, playlist, load/select subtitles,...):
|
||||
|
||||
- `up`, `down` - Select previous/next item.
|
||||
- `left`, `right` - Back to parent menu or close, activate item.
|
||||
- `enter` - Activate item.
|
||||
- `enter` - Activate item or submenu.
|
||||
- `bs` (backspace) - Activate parent menu.
|
||||
- `esc` - Close menu.
|
||||
- `wheel_up`, `wheel_down` - Scroll menu.
|
||||
- `pgup`, `pgdwn`, `home`, `end` - Self explanatory.
|
||||
- `ctrl+f` or `\` - In case `menu_type_to_search` is disabled, these two trigger the menu search instead.
|
||||
- `ctrl+f` or `\` - In case `menu_type_to_search` config option is disabled, these two trigger the menu search instead.
|
||||
- `ctrl+enter` - Submits a search in menus without instant search.
|
||||
- `ctrl+backspace` - Delete search query by word.
|
||||
- `shift+backspace` - Clear search query.
|
||||
- `ctrl+up/down` - Move selected item in menus that support it (playlist).
|
||||
- `ctrl+up/down/pgup/pgdwn/home/end` - Move selected item in menus that support it (playlist).
|
||||
- `del` - Delete selected item in menus that support it (playlist).
|
||||
- `shift+enter`, `shift+right` - Activate item without closing the menu.
|
||||
- `shift+enter`, `shift+click` - Activate item without closing the menu. Might not be supported by all menus.
|
||||
- `alt+enter`, `alt+click` - In file navigating menus, opens a directory in mpv instead of navigating to its contents.
|
||||
|
||||
Click on a faded parent menu to go back to it.
|
||||
@@ -251,7 +252,7 @@ Switch stream quality. This is just a basic re-assignment of `ytdl-format` mpv p
|
||||
|
||||
#### `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`
|
||||
|
||||
@@ -445,19 +446,6 @@ To see all the commands you can bind keys or menu items to, refer to [mpv's list
|
||||
|
||||
## Messages
|
||||
|
||||
### `uosc-version <version>`
|
||||
|
||||
Broadcasts the uosc version during script initialization. Useful if you want to detect that uosc is installed. Example:
|
||||
|
||||
```lua
|
||||
-- Register response handler
|
||||
mp.register_script_message('uosc-version', function(version)
|
||||
print('uosc version', version)
|
||||
end)
|
||||
```
|
||||
|
||||
## Message handlers
|
||||
|
||||
**uosc** listens on some messages that can be sent with `script-message-to uosc` command. Example:
|
||||
|
||||
```
|
||||
@@ -474,259 +462,12 @@ Parameters
|
||||
|
||||
ID (title) of the submenu, including `>` subsections as defined in `input.conf`. It has to be match the title exactly.
|
||||
|
||||
### `open-menu <menu_json> [submenu_id]`
|
||||
## Scripting API
|
||||
|
||||
A message other scripts can send to open a uosc menu serialized as JSON. You can optionally pass a `submenu_id` to pre-open a submenu. The ID is the submenu title chain leading to the submenu concatenated with `>`, for example `Tools > Aspect ratio`.
|
||||
|
||||
Menu data structure:
|
||||
|
||||
```
|
||||
Menu {
|
||||
type?: string;
|
||||
title?: string;
|
||||
items: Item[];
|
||||
selected_index?: integer;
|
||||
keep_open?: boolean;
|
||||
on_close?: string | string[];
|
||||
on_search?: string | string[];
|
||||
on_paste?: string | string[];
|
||||
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
||||
search_debounce?: 'submit' | number; // default: 0
|
||||
search_suggestion?: string;
|
||||
search_submenus?: boolean;
|
||||
}
|
||||
|
||||
Item = Command | Submenu;
|
||||
|
||||
Submenu {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
items: Item[];
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
align?: 'left'|'center'|'right';
|
||||
muted?: boolean;
|
||||
separator?: boolean;
|
||||
keep_open?: boolean;
|
||||
on_search?: string | string[];
|
||||
on_paste?: string | string[];
|
||||
search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand
|
||||
search_debounce?: 'submit' | number; // default: 0
|
||||
search_suggestion?: string;
|
||||
search_submenus?: boolean;
|
||||
}
|
||||
|
||||
Command {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
value: string | string[];
|
||||
active?: integer;
|
||||
selectable?: boolean;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
align?: 'left'|'center'|'right';
|
||||
muted?: boolean;
|
||||
separator?: boolean;
|
||||
keep_open?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
When `Command.value` is a string, it'll be passed to `mp.command(value)`. If it's a table (array) of strings, it'll be used as `mp.commandv(table.unpack(value))`. The same goes for `Menu.on_close` and `on_search`. `on_search` additionally appends the current search string as the last parameter.
|
||||
|
||||
`Menu.type` is used to refer to this menu in `update-menu` and `close-menu`.
|
||||
While the menu is open this value will be available in `user-data/uosc/menu/type` and the `shared-script-properties` entry `uosc-menu-type`. If no type was provided, those will be set to `'undefined'`.
|
||||
|
||||
`search_style` can be:
|
||||
- `on_demand` (_default_) - Search input pops up when user starts typing, or presses `/` or `ctrl+f`, depending on user configuration. It disappears on `shift+backspace`, or when input text is cleared.
|
||||
- `palette` - Search input is always visible and can't be disabled. In this mode, menu `title` is used as input placeholder when no text has been entered yet.
|
||||
- `disabled` - Menu can't be searched.
|
||||
|
||||
`search_debounce` controls how soon the search happens after the last character was entered in milliseconds. Entering new character resets the timer. Defaults to `300`. It can also have a special value `'submit'`, which triggers a search only after `ctrl+enter` was pressed.
|
||||
|
||||
`search_submenus` makes uosc's internal search handler (when no `on_search` callback is defined) look into submenus as well, effectively flattening the menu for the duration of the search. This property is inherited by all submenus.
|
||||
|
||||
`search_suggestion` fills menu search with initial query string. Useful for example when you want to implement something like subtitle downloader, you'd set it to current file name.
|
||||
|
||||
`item.icon` property accepts icon names. You can pick one from here: [Google Material Icons](https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded)\
|
||||
There is also a special icon name `spinner` which will display a rotating spinner. Along with a no-op command on an item and `keep_open=true`, this can be used to display placeholder menus/items that are still loading.
|
||||
|
||||
`on_paste` is triggered when user pastes a string while menu is opened. Works the same as `on_search`.
|
||||
|
||||
When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child if child doesn't overwrite it.
|
||||
|
||||
It's usually not necessary to define `selected_index` as it'll default to the first `active` item, or 1st item in the list.
|
||||
|
||||
Example:
|
||||
|
||||
```lua
|
||||
local utils = require('mp.utils')
|
||||
local menu = {
|
||||
type = 'menu_type',
|
||||
title = 'Custom menu',
|
||||
items = {
|
||||
{title = 'Foo', hint = 'foo', value = 'quit'},
|
||||
{title = 'Bar', hint = 'bar', value = 'quit', active = true},
|
||||
}
|
||||
}
|
||||
local json = utils.format_json(menu)
|
||||
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
|
||||
```
|
||||
|
||||
### `update-menu <menu_json>`
|
||||
|
||||
Updates currently opened menu with the same `type`.
|
||||
|
||||
The difference between this and `open-menu` is that if the same type menu is already open, `open-menu` will reset the menu as if it was newly opened, while `update-menu` will update it's data.
|
||||
|
||||
`update-menu`, along with `{menu/item}.keep_open` property and `item.command` that sends a message back can be used to create a self updating menu with some limited UI. Example:
|
||||
|
||||
```lua
|
||||
local utils = require('mp.utils')
|
||||
local script_name = mp.get_script_name()
|
||||
local state = {
|
||||
checkbox = 'no',
|
||||
radio = 'bar'
|
||||
}
|
||||
|
||||
function command(str)
|
||||
return string.format('script-message-to %s %s', script_name, str)
|
||||
end
|
||||
|
||||
function create_menu_data()
|
||||
return {
|
||||
type = 'test_menu',
|
||||
title = 'Test menu',
|
||||
keep_open = true,
|
||||
items = {
|
||||
{
|
||||
title = 'Checkbox',
|
||||
icon = state.checkbox == 'yes' and 'check_box' or 'check_box_outline_blank',
|
||||
value = command('set-state checkbox ' .. (state.checkbox == 'yes' and 'no' or 'yes'))
|
||||
},
|
||||
{
|
||||
title = 'Radio',
|
||||
hint = state.radio,
|
||||
items = {
|
||||
{
|
||||
title = 'Foo',
|
||||
icon = state.radio == 'foo' and 'radio_button_checked' or 'radio_button_unchecked',
|
||||
value = command('set-state radio foo')
|
||||
},
|
||||
{
|
||||
title = 'Bar',
|
||||
icon = state.radio == 'bar' and 'radio_button_checked' or 'radio_button_unchecked',
|
||||
value = command('set-state radio bar')
|
||||
},
|
||||
{
|
||||
title = 'Baz',
|
||||
icon = state.radio == 'baz' and 'radio_button_checked' or 'radio_button_unchecked',
|
||||
value = command('set-state radio baz')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title = 'Submit',
|
||||
icon = 'check',
|
||||
value = command('submit'),
|
||||
keep_open = false
|
||||
},
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
mp.add_forced_key_binding('t', 'test_menu', function()
|
||||
local json = utils.format_json(create_menu_data())
|
||||
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
|
||||
end)
|
||||
|
||||
mp.register_script_message('set-state', function(prop, value)
|
||||
state[prop] = value
|
||||
-- Update currently opened menu
|
||||
local json = utils.format_json(create_menu_data())
|
||||
mp.commandv('script-message-to', 'uosc', 'update-menu', json)
|
||||
end)
|
||||
|
||||
mp.register_script_message('submit', function(prop, value)
|
||||
-- Do something with state
|
||||
end)
|
||||
```
|
||||
|
||||
### `close-menu [type]`
|
||||
|
||||
Closes the menu. If the optional parameter `type` is provided, then the menu only
|
||||
closes if it matches `Menu.type` of the currently open menu.
|
||||
|
||||
### `set <prop> <value>`
|
||||
|
||||
Tell **uosc** to set an external property to this value. Currently, this is only used to set/display control button active state and badges:
|
||||
|
||||
In your script, set the value of `foo` to `1`.
|
||||
|
||||
```lua
|
||||
mp.commandv('script-message-to', 'uosc', 'set', 'foo', 1)
|
||||
```
|
||||
|
||||
`foo` can now be used as a `toggle` or `cycle` property by specifying its owner with a `@{script_name}` suffix:
|
||||
|
||||
```
|
||||
toggle:icon_name:foo@script_name
|
||||
cycle:icon_name:foo@script_name:no/yes!
|
||||
```
|
||||
|
||||
If user clicks this `toggle` or `cycle` button, uosc will send a `set` message back to the script owner. You can then listen to this message, do what you need with the new value, and update uosc state accordingly:
|
||||
|
||||
```lua
|
||||
-- Send initial value so that the button has a correct active state
|
||||
mp.commandv('script-message-to', 'uosc', 'set', 'foo', 'yes')
|
||||
-- Listen for changes coming from `toggle` or `cycle` button
|
||||
mp.register_script_message('set', function(prop, value)
|
||||
-- ... do something with `value`
|
||||
-- Update uosc external prop
|
||||
mp.commandv('script-message-to', 'uosc', 'set', 'foo', value)
|
||||
end)
|
||||
```
|
||||
|
||||
External properties can also be used as control button badges:
|
||||
|
||||
```
|
||||
controls=command:icon_name:command_name#foo@script_name?My foo button
|
||||
```
|
||||
|
||||
### `overwrite-binding <name> <command>`
|
||||
|
||||
Allows a overwriting handling of uosc built in bindings. Useful for 3rd party scripts that specialize in a specific domain to replace built in menus or behaviors provided by existing bindings.
|
||||
|
||||
Example that reroutes uosc's basic stream quality menu to [christoph-heinrich/mpv-quality-menu](https://github.com/christoph-heinrich/mpv-quality-menu):
|
||||
|
||||
```lua
|
||||
mp.commandv('script-message-to', 'uosc', 'overwrite-binding', 'stream-quality', 'script-binding quality_menu/video_formats_toggle')
|
||||
```
|
||||
|
||||
To cancel the overwrite and return to default behavior, just omit the `<command>` parameter.
|
||||
|
||||
### `disable-elements <script_id> <element_ids>`
|
||||
|
||||
Set what uosc elements your script wants to disable. To cancel or re-enable them, send the message again with an empty string in place of `element_ids`.
|
||||
|
||||
```lua
|
||||
mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(), 'timeline,volume')
|
||||
```
|
||||
|
||||
Using `'user'` as `script_id` will overwrite user's `disable_elements` config. Elements will be enabled only when neither user, nor any script requested them to be disabled.
|
||||
3rd party script developers can use our messaging API to integrate with uosc, or use it to render their menus. Documentation is available in [uosc Wiki](https://github.com/tomasklaen/uosc/wiki).
|
||||
|
||||
## Contributing
|
||||
|
||||
### Setup
|
||||
|
||||
If you want to test or work on something that involves ziggy (our multitool binary, currently handles searching & downloading subtitles), you first need to build it with:
|
||||
|
||||
```
|
||||
tools/build ziggy
|
||||
```
|
||||
|
||||
This requires [`go`](https://go.dev/dl/) to be installed and in path. If you don't want to bother with installing go, and there were no changes to ziggy, you can just use the binaries from [latest release](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip). Place folder `scripts/uosc/bin` from `uosc.zip` into `src/uosc/bin`.
|
||||
|
||||
### Localization
|
||||
|
||||
If you want to help localizing uosc by either adding a new locale or fixing one that is not up to date, start by running this while in the repository root:
|
||||
@@ -741,6 +482,16 @@ This will parse the codebase for localization strings and use them to either upd
|
||||
|
||||
You can then navigate to `src/uosc/intl/languagecode.json` and start translating.
|
||||
|
||||
### Setting up binaries
|
||||
|
||||
If you want to test or work on something that involves ziggy (our multitool binary, currently handles searching & downloading subtitles), you first need to build it with:
|
||||
|
||||
```
|
||||
tools/build ziggy
|
||||
```
|
||||
|
||||
This requires [`go`](https://go.dev/dl/) to be installed and in path. If you don't want to bother with installing go, and there were no changes to ziggy, you can just use the binaries from [latest release](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip). Place folder `scripts/uosc/bin` from `uosc.zip` into `src/uosc/bin`.
|
||||
|
||||
## FAQ
|
||||
|
||||
#### Why is the release zip size in megabytes? Isn't this just a lua script?
|
||||
|
@@ -43,6 +43,7 @@ progress_line_width=20
|
||||
# {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
|
||||
# - 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:
|
||||
# `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility
|
||||
# - `{disposition}` can be one of:
|
||||
@@ -109,7 +110,8 @@ menu_type_to_search=yes
|
||||
# Can be: never, no-border, always
|
||||
top_bar=no-border
|
||||
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
|
||||
top_bar_title=yes
|
||||
# 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
|
||||
# the top bar, or calling `toggle-title` binding
|
||||
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_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
|
||||
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
|
||||
# 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=~/
|
||||
# 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).
|
||||
|
@@ -1,6 +1,6 @@
|
||||
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
|
||||
local Button = class(Element)
|
||||
@@ -17,13 +17,15 @@ function Button:init(id, props)
|
||||
self.badge = props.badge
|
||||
self.foreground = props.foreground or fg
|
||||
self.background = props.background or bg
|
||||
---@type fun()
|
||||
self.is_clickable = true
|
||||
---@type fun()|nil
|
||||
self.on_click = props.on_click
|
||||
Element.init(self, id, props)
|
||||
end
|
||||
|
||||
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
|
||||
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
|
||||
-- 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
|
||||
@@ -37,17 +39,20 @@ function Button:render()
|
||||
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
|
||||
|
||||
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_or_active = is_hover or self.active
|
||||
local foreground = self.active and self.background or self.foreground
|
||||
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
|
||||
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, {
|
||||
color = (self.active or not is_hover) and background or foreground,
|
||||
radius = state.radius,
|
||||
opacity = visibility * (self.active and 1 or (is_hover and 0.3 or config.opacity.controls)),
|
||||
opacity = visibility * background_opacity,
|
||||
})
|
||||
end
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
local Element = require('elements/Element')
|
||||
local Button = require('elements/Button')
|
||||
local CycleButton = require('elements/CycleButton')
|
||||
local ManagedButton = require('elements/ManagedButton')
|
||||
local Speed = require('elements/Speed')
|
||||
|
||||
-- sizing:
|
||||
@@ -163,6 +164,19 @@ function Controls:init_options()
|
||||
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
|
||||
if badge then self:register_badge_updater(badge, element) 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
|
||||
if not Elements.speed then
|
||||
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
|
||||
|
@@ -35,6 +35,12 @@ function CycleButton:init(id, props)
|
||||
end
|
||||
|
||||
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 '')
|
||||
local index = itable_find(self.states, function(state) return state.value == value end)
|
||||
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 onTweenEnd() self.forced_visibility = nil end
|
||||
if self.enabled then
|
||||
self:tween_property('forced_visibility', 1, getTo, onTweenEnd)
|
||||
self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
|
||||
else
|
||||
onTweenEnd()
|
||||
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
|
||||
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()
|
||||
if self.size == 0 then return end
|
||||
@@ -186,8 +184,10 @@ function Timeline:render()
|
||||
self:handle_cursor_down()
|
||||
cursor:once('primary_up', function() self:handle_cursor_up() end)
|
||||
end)
|
||||
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
|
||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
||||
if options.timeline_step ~= 0 then
|
||||
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
|
||||
|
||||
local ass = assdraw.ass_new()
|
||||
@@ -251,15 +251,11 @@ function Timeline:render()
|
||||
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
|
||||
|
||||
-- Uncached ranges
|
||||
local buffered_playtime = nil
|
||||
if state.uncached_ranges then
|
||||
local opts = {size = 80, anchor_y = fby}
|
||||
local texture_char = visibility > 0 and 'b' or 'a'
|
||||
local offset = opts.size / (visibility > 0 and 24 or 28)
|
||||
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
|
||||
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]))
|
||||
@@ -321,7 +317,7 @@ function Timeline:render()
|
||||
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}
|
||||
if visibility > 0 then
|
||||
cursor:zone('primary_down', circle, function()
|
||||
cursor:zone('primary_click', circle, function()
|
||||
mp.commandv('seek', chapter.time, 'absolute+exact')
|
||||
end)
|
||||
end
|
||||
@@ -377,14 +373,15 @@ function Timeline:render()
|
||||
if text_opacity > 0 then
|
||||
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
|
||||
-- Upcoming cache time
|
||||
if buffered_playtime and options.buffered_time_threshold > 0
|
||||
and buffered_playtime < options.buffered_time_threshold then
|
||||
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
|
||||
if cache_duration and options.buffered_time_threshold > 0
|
||||
and cache_duration < options.buffered_time_threshold then
|
||||
local margin = 5 * state.scale
|
||||
local x, align = fbx + margin, 4
|
||||
local cache_opts = {
|
||||
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 time_width = timestamp_width(state.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')
|
||||
|
||||
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
|
||||
|
||||
---@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 ]]
|
||||
---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
|
||||
|
||||
---@class TopBar : Element
|
||||
local TopBar = class(Element)
|
||||
@@ -49,48 +9,30 @@ function TopBar:new() return Class.new(self) --[[@as TopBar]] end
|
||||
function TopBar:init()
|
||||
Element.init(self, 'top_bar', {render_order = 4})
|
||||
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.main_title, self.alt_title = nil, nil
|
||||
|
||||
local function get_maximized_command()
|
||||
local function maximized_command()
|
||||
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')
|
||||
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
|
||||
return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
|
||||
end
|
||||
|
||||
-- Order aligns from right to left
|
||||
self.buttons = {
|
||||
TopBarButton:new('tb_close', {
|
||||
icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
}
|
||||
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
|
||||
local max = {icon = 'crop_square', command = maximized_command}
|
||||
local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
|
||||
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
|
||||
|
||||
self:decide_titles()
|
||||
self:decide_enabled()
|
||||
self:update_dimensions()
|
||||
end
|
||||
|
||||
function TopBar:destroy()
|
||||
for _, button in ipairs(self.buttons) do button:destroy() end
|
||||
Element.destroy(self)
|
||||
end
|
||||
|
||||
function TopBar:decide_enabled()
|
||||
if options.top_bar == 'no-border' then
|
||||
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'
|
||||
end
|
||||
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
|
||||
|
||||
function TopBar:decide_titles()
|
||||
@@ -136,22 +75,12 @@ end
|
||||
function TopBar:update_dimensions()
|
||||
self.size = round(options.top_bar_size * state.scale)
|
||||
self.icon_size = round(self.size * 0.5)
|
||||
self.spacing = math.ceil(self.size * 0.25)
|
||||
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
|
||||
self.button_width = round(self.size * 1.15)
|
||||
self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
|
||||
local window_border_size = Elements:v('window_border', 'size', 0)
|
||||
self.ax = window_border_size
|
||||
self.ay = window_border_size
|
||||
self.bx = display.width - 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
|
||||
|
||||
function TopBar:toggle_title()
|
||||
@@ -199,15 +128,54 @@ function TopBar:render()
|
||||
local visibility = self:get_visibility()
|
||||
if visibility <= 0 then return end
|
||||
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
|
||||
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 spacing = 1
|
||||
local title_ax = self.ax + bg_margin
|
||||
local title_ay = self.ay + bg_margin
|
||||
local max_bx = self.title_bx - self.spacing
|
||||
local left_aligned = options.top_bar_controls == 'left'
|
||||
local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
|
||||
|
||||
-- Playlist position
|
||||
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 .. '}/'
|
||||
.. state.playlist_count
|
||||
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 = {
|
||||
ax = title_ax,
|
||||
ax = ax,
|
||||
ay = title_ay,
|
||||
bx = round(title_ax + text_width(text, opts) + padding * 2),
|
||||
by = self.by - bg_margin,
|
||||
bx = ax + rect_width,
|
||||
by = self.by - margin,
|
||||
}
|
||||
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
|
||||
and 1 or config.opacity.playlist_position
|
||||
@@ -229,14 +199,14 @@ function TopBar:render()
|
||||
})
|
||||
end
|
||||
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
|
||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
|
||||
end
|
||||
|
||||
-- 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
|
||||
local main_title = self.show_alt_title and self.alt_title or self.main_title
|
||||
if main_title then
|
||||
@@ -247,11 +217,13 @@ function TopBar:render()
|
||||
opacity = visibility,
|
||||
border = options.text_border * state.scale,
|
||||
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 by = self.by - bg_margin
|
||||
local title_rect = {ax = title_ax, ay = title_ay, bx = bx, by = by}
|
||||
local rect_ideal_width = round(text_width(main_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 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
|
||||
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, {
|
||||
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
|
||||
end
|
||||
|
||||
@@ -277,12 +251,17 @@ function TopBar:render()
|
||||
border_color = bg,
|
||||
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)
|
||||
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,
|
||||
})
|
||||
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
|
||||
end
|
||||
|
||||
@@ -291,7 +270,8 @@ function TopBar:render()
|
||||
local padding_half = round(padding / 2)
|
||||
local font_size = self.font_size * 0.8
|
||||
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 chapter_end = next_chapter and next_chapter.time or state.duration or 0
|
||||
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
|
||||
|
||||
-- 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 = {
|
||||
ax = title_ax,
|
||||
ax = ax,
|
||||
ay = title_ay,
|
||||
bx = round(math.min(
|
||||
max_bx - remaining_box_width - spacing,
|
||||
title_ax + text_width(text, opts) + padding * 2
|
||||
)),
|
||||
bx = ax + rect_width,
|
||||
by = title_ay + height,
|
||||
}
|
||||
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, {
|
||||
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
|
||||
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
||||
|
||||
-- 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
|
||||
opts.clip = nil
|
||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||
|
@@ -85,30 +85,56 @@ end
|
||||
-- Tooltip.
|
||||
---@param element Rect
|
||||
---@param value string|number
|
||||
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean}
|
||||
---@param opts? {size?: number; align?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean; invert_colors?: boolean}
|
||||
function ass_mt:tooltip(element, value, opts)
|
||||
if value == '' then return end
|
||||
opts = opts or {}
|
||||
opts.size = opts.size or round(16 * state.scale)
|
||||
opts.border = options.text_border * state.scale
|
||||
opts.border_color = bg
|
||||
opts.border_color = opts.invert_colors and fg or bg
|
||||
opts.margin = opts.margin or round(10 * state.scale)
|
||||
opts.lines = opts.lines or 1
|
||||
opts.color = opts.invert_colors and bg or fg
|
||||
local offset = opts.offset or 2
|
||||
local padding_y = round(opts.size / 6)
|
||||
local padding_x = round(opts.size / 3)
|
||||
local offset = opts.offset or 2
|
||||
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
|
||||
local x = element.ax + (element.bx - element.ax) / 2
|
||||
local y = align_top and element.ay - offset or element.by + offset
|
||||
local width_half = (opts.width_overwrite or text_width(value, opts)) / 2 + padding_x
|
||||
local min_edge_distance = width_half + opts.margin + Elements:v('window_border', 'size', 0)
|
||||
x = clamp(min_edge_distance, x, display.width - min_edge_distance)
|
||||
local ax, bx = round(x - width_half), round(x + width_half)
|
||||
local ay = (align_top and y - opts.size * opts.lines - 2 * padding_y or y)
|
||||
local by = (align_top and y or y + opts.size * opts.lines + 2 * padding_y)
|
||||
self:rect(ax, ay, bx, by, {color = bg, opacity = config.opacity.tooltip, radius = state.radius})
|
||||
local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
|
||||
local height = opts.size * opts.lines + 2 * padding_y
|
||||
local width_half, height_half = width / 2, height / 2
|
||||
local margin = opts.margin + Elements:v('window_border', 'size', 0)
|
||||
local align = opts.align or 8
|
||||
|
||||
local x, y = 0, 0 -- center of tooltip
|
||||
|
||||
-- Flip alignment to other side when not enough space
|
||||
if opts.responsive ~= false then
|
||||
if align == 8 then
|
||||
if element.ay - offset - height < margin then align = 2 end
|
||||
elseif align == 2 then
|
||||
if element.by + offset + height > display.height - margin then align = 8 end
|
||||
elseif align == 6 then
|
||||
if element.bx + offset + width > display.width - margin then align = 4 end
|
||||
elseif align == 4 then
|
||||
if element.ax - offset - width < margin then align = 6 end
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate tooltip center based on alignment
|
||||
if align == 8 or align == 2 then
|
||||
x = clamp(margin + width_half, element.ax + (element.bx - element.ax) / 2, display.width - margin - width_half)
|
||||
y = align == 8 and element.ay - offset - height_half or element.by + offset + height_half
|
||||
else
|
||||
x = align == 6 and element.bx + offset + width_half or element.ax - offset - width_half
|
||||
y = clamp(margin + height_half, element.ay + (element.by - element.ay) / 2, display.height - margin - height_half)
|
||||
end
|
||||
|
||||
-- Draw
|
||||
local ax, ay, bx, by = round(x - width_half), round(y - height_half), round(x + width_half), round(y + height_half)
|
||||
self:rect(ax, ay, bx, by, {
|
||||
color = opts.invert_colors and fg or bg, opacity = config.opacity.tooltip, radius = state.radius
|
||||
})
|
||||
local func = opts.timestamp and self.timestamp or self.txt
|
||||
func(self, x, align_top and y - padding_y or y + padding_y, align_top and 2 or 8, tostring(value), opts)
|
||||
func(self, x, y, 5, tostring(value), opts)
|
||||
return {ax = element.ax, ay = ay, bx = element.bx, by = by}
|
||||
end
|
||||
|
||||
|
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 languages = get_languages()
|
||||
for i = #languages, 1, -1 do
|
||||
lang = languages[i]
|
||||
if (lang == 'en') then
|
||||
data = {}
|
||||
else
|
||||
for _, lang in ipairs(languages) do
|
||||
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
|
||||
end
|
||||
end
|
||||
|
||||
local romanization = {}
|
||||
|
@@ -1,6 +1,9 @@
|
||||
---@param data MenuData
|
||||
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
|
||||
function open_command_menu(data, opts)
|
||||
opts = opts or {}
|
||||
local menu
|
||||
|
||||
local function run_command(command)
|
||||
if type(command) == 'string' then
|
||||
mp.command(command)
|
||||
@@ -9,14 +12,21 @@ function open_command_menu(data, opts)
|
||||
mp.commandv(unpack(command))
|
||||
end
|
||||
end
|
||||
---@type MenuOptions
|
||||
local menu_opts = {}
|
||||
if opts then
|
||||
menu_opts.mouse_nav = opts.mouse_nav
|
||||
if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end
|
||||
|
||||
local function callback(event)
|
||||
if type(menu.root.callback) == 'table' then
|
||||
---@diagnostic disable-next-line: deprecated
|
||||
mp.commandv(unpack(itable_join({'script-message-to'}, menu.root.callback, {utils.format_json(event)})))
|
||||
elseif event.type == 'activate' then
|
||||
run_command(event.value)
|
||||
menu:close()
|
||||
end
|
||||
local menu = Menu:open(data, run_command, menu_opts)
|
||||
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
|
||||
end
|
||||
|
||||
---@type MenuOptions
|
||||
local menu_opts = table_assign_props({}, opts, {'mouse_nav'})
|
||||
menu = Menu:open(data, callback, menu_opts)
|
||||
if opts.submenu then menu:activate_menu(opts.submenu) end
|
||||
return menu
|
||||
end
|
||||
|
||||
@@ -29,7 +39,8 @@ function toggle_menu_with_items(opts)
|
||||
end
|
||||
end
|
||||
|
||||
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
|
||||
---@alias EventRemove {type: 'remove' | 'delete', index: number; value: any; menu_id: string;}
|
||||
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[];on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: EventRemove); on_delete?: fun(event: EventRemove)}
|
||||
function create_self_updating_menu_opener(opts)
|
||||
return function()
|
||||
if Menu:is_open(opts.type) then
|
||||
@@ -64,30 +75,88 @@ function create_self_updating_menu_opener(opts)
|
||||
|
||||
local initial_items, selected_index = opts.serializer(list, active)
|
||||
|
||||
---@type MenuAction[]
|
||||
local actions = opts.actions or {}
|
||||
if opts.on_move then
|
||||
actions[#actions + 1] = {name = 'move_up', icon = 'arrow_upward', label = t('Move up')}
|
||||
actions[#actions + 1] = {name = 'move_down', icon = 'arrow_downward', label = t('Move down')}
|
||||
end
|
||||
if opts.on_remove or opts.on_delete then
|
||||
local label = opts.on_remove and t('Remove') or t('Delete')
|
||||
if opts.on_remove and opts.on_delete then label = t('Remove (ctrl to delete)') end
|
||||
actions[#actions + 1] = {name = 'remove', icon = 'delete', label = label}
|
||||
end
|
||||
|
||||
function remove_or_delete(index, value, menu_id, modifiers)
|
||||
if opts.on_remove and opts.on_delete then
|
||||
local method = modifiers == 'ctrl' and 'delete' or 'remove'
|
||||
local handler = method == 'delete' and opts.on_delete or opts.on_remove
|
||||
if handler then
|
||||
handler({type = method, value = value, index = index, menu_id = menu_id})
|
||||
end
|
||||
elseif opts.on_remove or opts.on_delete then
|
||||
local method = opts.on_delete and 'delete' or 'remove'
|
||||
local handler = opts.on_delete or opts.on_remove
|
||||
if handler then
|
||||
handler({type = method, value = value, index = index, menu_id = menu_id})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Items and active_index are set in the handle_prop_change callback, since adding
|
||||
-- a property observer triggers its handler immediately, we just let that initialize the items.
|
||||
menu = Menu:open(
|
||||
{
|
||||
menu = Menu:open({
|
||||
type = opts.type,
|
||||
title = opts.title,
|
||||
items = initial_items,
|
||||
actions = actions,
|
||||
selected_index = selected_index,
|
||||
on_paste = opts.on_paste,
|
||||
},
|
||||
opts.on_select, {
|
||||
on_open = function()
|
||||
on_move = opts.on_move and 'callback' or nil,
|
||||
on_close = 'callback',
|
||||
}, function(event)
|
||||
if event.type == 'activate' then
|
||||
if (event.action == 'move_up' or event.action == 'move_down') and opts.on_move then
|
||||
local to_index = event.index + (event.action == 'move_up' and -1 or 1)
|
||||
if to_index > 1 and to_index <= #menu.current.items then
|
||||
opts.on_move({
|
||||
type = 'move',
|
||||
from_index = event.index,
|
||||
to_index = to_index,
|
||||
menu_id = menu.current.id,
|
||||
})
|
||||
menu:select_index(to_index)
|
||||
menu:scroll_to_index(to_index, nil, true)
|
||||
end
|
||||
elseif event.action == 'remove' and (opts.on_remove or opts.on_delete) then
|
||||
remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
|
||||
elseif itable_has({'', 'shift'}, event.modifiers) then
|
||||
opts.on_activate(event --[[@as MenuEventActivate]])
|
||||
if event.modifiers == 'shift' then menu:close() end
|
||||
end
|
||||
elseif event.type == 'key' then
|
||||
if event.id == 'enter' then
|
||||
menu:close()
|
||||
elseif event.key == 'del' then
|
||||
if itable_has({'', 'ctrl'}, event.modifiers) then
|
||||
remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
|
||||
end
|
||||
end
|
||||
elseif event.type == 'paste' and opts.on_paste then
|
||||
opts.on_paste(event --[[@as MenuEventPaste]])
|
||||
elseif event.type == 'close' then
|
||||
mp.unobserve_property(handle_list_prop_change)
|
||||
mp.unobserve_property(handle_active_prop_change)
|
||||
menu:close()
|
||||
elseif event.type == 'move' and opts.on_move then
|
||||
opts.on_move(event --[[@as MenuEventMove]])
|
||||
elseif event.type == 'remove' and opts.on_move then
|
||||
end
|
||||
end)
|
||||
|
||||
mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
|
||||
if opts.active_prop then
|
||||
mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
|
||||
end
|
||||
end,
|
||||
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
|
||||
|
||||
@@ -95,20 +164,23 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
||||
local function serialize_tracklist(tracklist)
|
||||
local items = {}
|
||||
|
||||
if download_command then
|
||||
items[#items + 1] = {
|
||||
title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
|
||||
}
|
||||
end
|
||||
if load_command then
|
||||
items[#items + 1] = {
|
||||
title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}',
|
||||
title = t('Load'),
|
||||
bold = true,
|
||||
italic = true,
|
||||
hint = t('open file'),
|
||||
value = '{load}',
|
||||
actions = download_command
|
||||
and {{name = 'download', icon = 'language', label = t('Search online')}}
|
||||
or nil,
|
||||
}
|
||||
end
|
||||
if #items > 0 then
|
||||
items[#items].separator = true
|
||||
end
|
||||
|
||||
local track_prop_index = tonumber(mp.get_property(track_prop))
|
||||
local first_item_index = #items + 1
|
||||
local active_index = 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
|
||||
if track.type == track_type then
|
||||
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
|
||||
|
||||
if track.lang then h(track.lang:upper()) end
|
||||
if track.lang then h(track.lang) end
|
||||
if track['demux-h'] then
|
||||
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
||||
end
|
||||
@@ -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)),
|
||||
hint = table.concat(hint_values, ', '),
|
||||
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
|
||||
active_index = #items
|
||||
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
|
||||
end
|
||||
|
||||
local function handle_select(value)
|
||||
if value == '{download}' then
|
||||
mp.command(download_command)
|
||||
elseif value == '{load}' then
|
||||
mp.command(load_command)
|
||||
---@param event MenuEventActivate
|
||||
local function handle_activate(event)
|
||||
if event.value == '{load}' then
|
||||
mp.command(event.action == 'download' and download_command or load_command)
|
||||
else
|
||||
mp.commandv('set', track_prop, value and value or 'no')
|
||||
mp.commandv('set', track_prop, event.value and event.value or 'no')
|
||||
|
||||
-- If subtitle track was selected, assume the user also wants to see it
|
||||
if value and track_type == 'sub' then
|
||||
if event.value and track_type == 'sub' then
|
||||
mp.commandv('set', 'sub-visibility', 'yes')
|
||||
end
|
||||
end
|
||||
@@ -181,132 +253,40 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
|
||||
type = track_type,
|
||||
list_prop = 'track-list',
|
||||
serializer = serialize_tracklist,
|
||||
on_select = handle_select,
|
||||
on_paste = function(path) load_track(track_type, path) end,
|
||||
on_activate = handle_activate,
|
||||
on_paste = function(event) load_track(track_type, event.value) end,
|
||||
})
|
||||
end
|
||||
|
||||
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
|
||||
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_close?: fun()}
|
||||
|
||||
-- Opens a file navigation menu with items inside `directory_path`.
|
||||
---@param directory_path string
|
||||
---@param handle_select fun(path: string, mods: Modifiers): nil
|
||||
---@param handle_activate fun(event: MenuEventActivate)
|
||||
---@param opts NavigationMenuOptions
|
||||
function open_file_navigation_menu(directory_path, handle_select, opts)
|
||||
directory = serialize_path(normalize_path(directory_path))
|
||||
opts = opts or {}
|
||||
|
||||
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
|
||||
function open_file_navigation_menu(directory_path, handle_activate, opts)
|
||||
if directory_path == '{drives}' then
|
||||
if state.platform ~= 'windows' then directory_path = '/' end
|
||||
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
|
||||
|
||||
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 {}
|
||||
---@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({
|
||||
name = 'subprocess',
|
||||
capture_stdout = true,
|
||||
@@ -323,24 +303,162 @@ function open_drives_menu(handle_select, opts)
|
||||
items[#items + 1] = {
|
||||
title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
|
||||
}
|
||||
if opts.selected_path == drive_path then selected_index = #items end
|
||||
if selected_path == drive_path then selected_index = #items end
|
||||
end
|
||||
end
|
||||
else
|
||||
msg.error(process.stderr)
|
||||
return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr)
|
||||
end
|
||||
return items, selected_index
|
||||
end
|
||||
|
||||
return Menu:open(
|
||||
{type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index},
|
||||
handle_select
|
||||
)
|
||||
local serialized = serialize_path(path)
|
||||
if not serialized then
|
||||
return {}, 0, 'Couldn\'t serialize path "' .. path .. '.'
|
||||
end
|
||||
local files, directories, error = read_directory(serialized.path, {
|
||||
types = opts.allowed_types,
|
||||
hidden = options.show_hidden_files,
|
||||
})
|
||||
if error then
|
||||
return {}, 1, error
|
||||
end
|
||||
local is_root = not serialized.dirname
|
||||
|
||||
if not files or not directories then return {}, 0 end
|
||||
|
||||
sort_strings(directories)
|
||||
sort_strings(files)
|
||||
|
||||
-- Pre-populate items with parent directory selector if not at root
|
||||
-- Each item value is a serialized path table it points to.
|
||||
local items = {}
|
||||
|
||||
if is_root then
|
||||
if state.platform == 'windows' then
|
||||
items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true}
|
||||
end
|
||||
else
|
||||
items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true}
|
||||
end
|
||||
|
||||
back_path = items[#items] and items[#items].value
|
||||
local selected_index = #items + 1
|
||||
|
||||
for _, dir in ipairs(directories) do
|
||||
items[#items + 1] = {title = dir, 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
|
||||
|
||||
-- On demand menu items loading
|
||||
do
|
||||
local items = nil
|
||||
function get_menu_items()
|
||||
if items then return items end
|
||||
---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil
|
||||
local all_user_bindings = nil
|
||||
---@type MenuStackItem[]|nil
|
||||
local menu_items = nil
|
||||
|
||||
local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end
|
||||
|
||||
-- Returns all relevant bindings from `input.conf`, even if they are overwritten
|
||||
-- (same key bound to something else later) or have no keys (uosc menu items).
|
||||
function get_all_user_bindings()
|
||||
if all_user_bindings then return all_user_bindings end
|
||||
all_user_bindings = {}
|
||||
|
||||
local input_conf_property = mp.get_property_native('input-conf')
|
||||
local input_conf_iterator
|
||||
@@ -359,23 +477,53 @@ do
|
||||
|
||||
-- File doesn't exist
|
||||
if not input_conf_meta or not input_conf_meta.is_file then
|
||||
items = create_default_menu_items()
|
||||
return items
|
||||
menu_items = create_default_menu_items()
|
||||
return menu_items, all_user_bindings
|
||||
end
|
||||
|
||||
input_conf_iterator = io.lines(input_conf_path)
|
||||
end
|
||||
|
||||
for line in input_conf_iterator do
|
||||
local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$')
|
||||
local is_commented_out = key and key:sub(1, 1) == '#'
|
||||
|
||||
if comment and #comment > 0 then comment = comment:sub(2) end
|
||||
if command then command = trim(command) end
|
||||
|
||||
local is_menu_item = comment and is_uosc_menu_comment(comment)
|
||||
|
||||
if key
|
||||
-- Filter out stuff like `#F2`, which is clearly intended to be disabled
|
||||
and not (is_commented_out and #key > 1)
|
||||
-- Filter out comments that are not uosc menu items
|
||||
and (not is_commented_out or is_menu_item) then
|
||||
all_user_bindings[#all_user_bindings + 1] = {
|
||||
key = key,
|
||||
cmd = command,
|
||||
comment = comment or '',
|
||||
is_menu_item = is_menu_item,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return all_user_bindings
|
||||
end
|
||||
|
||||
function get_menu_items()
|
||||
if menu_items then return menu_items end
|
||||
|
||||
local all_user_bindings = get_all_user_bindings()
|
||||
local main_menu = {items = {}, items_by_command = {}}
|
||||
local by_id = {}
|
||||
|
||||
for line in input_conf_iterator do
|
||||
local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
|
||||
for _, bind in ipairs(all_user_bindings) do
|
||||
local key, command, comment = bind.key, bind.cmd, bind.comment
|
||||
local title = ''
|
||||
|
||||
if comment then
|
||||
local comments = split(comment, '#')
|
||||
local titles = itable_filter(comments, 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
|
||||
title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
|
||||
end
|
||||
@@ -399,7 +547,6 @@ do
|
||||
|
||||
target_menu = by_id[submenu_id]
|
||||
else
|
||||
if command == 'ignore' then break end
|
||||
-- If command is already in menu, just append the key to it
|
||||
if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
|
||||
local hint = target_menu.items_by_command[command].hint
|
||||
@@ -409,7 +556,7 @@ do
|
||||
if title_part:sub(1, 3) == '---' then
|
||||
local last_item = target_menu.items[#target_menu.items]
|
||||
if last_item then last_item.separator = true end
|
||||
else
|
||||
elseif command ~= 'ignore' then
|
||||
local item = {
|
||||
title = title_part,
|
||||
hint = not is_dummy and key or nil,
|
||||
@@ -430,20 +577,30 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
|
||||
return items
|
||||
menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
|
||||
return menu_items
|
||||
end
|
||||
end
|
||||
|
||||
-- Adapted from `stats.lua`
|
||||
function get_keybinds_items()
|
||||
local items = {}
|
||||
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
|
||||
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}
|
||||
end
|
||||
end
|
||||
|
||||
-- Sort
|
||||
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 items = {}
|
||||
---@type Menu
|
||||
local menu
|
||||
|
||||
for _, height in ipairs(config.stream_quality_options) do
|
||||
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
|
||||
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
|
||||
end
|
||||
|
||||
Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
|
||||
mp.set_property('ytdl-format', format)
|
||||
menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event)
|
||||
if event.type == 'activate' then
|
||||
mp.set_property('ytdl-format', event.value)
|
||||
|
||||
-- Reload the video to apply new format
|
||||
-- This is taken from https://github.com/jgreco/mpv-youtube-quality
|
||||
@@ -496,6 +656,9 @@ function open_stream_quality_menu()
|
||||
end
|
||||
mp.register_event('file-loaded', seeker)
|
||||
end
|
||||
|
||||
if event.modifiers ~= 'shift' then menu:close() end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -505,15 +668,14 @@ function open_open_file_menu()
|
||||
return
|
||||
end
|
||||
|
||||
---@type Menu | nil
|
||||
local menu
|
||||
local directory
|
||||
local active_file
|
||||
|
||||
if state.path == nil or is_protocol(state.path) then
|
||||
local serialized = serialize_path(get_default_directory())
|
||||
if serialized then
|
||||
directory = serialized.path
|
||||
directory = options.default_directory
|
||||
active_file = nil
|
||||
end
|
||||
else
|
||||
local serialized = serialize_path(state.path)
|
||||
if serialized then
|
||||
@@ -528,7 +690,6 @@ function open_open_file_menu()
|
||||
end
|
||||
|
||||
-- Update active file in directory navigation menu
|
||||
local menu = nil
|
||||
local function handle_file_loaded()
|
||||
if menu and menu:is_alive() then
|
||||
menu:activate_one_value(normalize_path(mp.get_property_native('path')))
|
||||
@@ -537,12 +698,14 @@ function open_open_file_menu()
|
||||
|
||||
menu = open_file_navigation_menu(
|
||||
directory,
|
||||
function(path, mods)
|
||||
if mods.ctrl then
|
||||
mp.commandv('loadfile', path, 'append')
|
||||
else
|
||||
mp.commandv('loadfile', path)
|
||||
Menu:close()
|
||||
function(event)
|
||||
if not menu then return end
|
||||
local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
|
||||
if itable_has({'ctrl', 'ctrl+shift'}, event.modifiers) then
|
||||
mp.commandv(command, event.value, 'append')
|
||||
elseif event.modifiers == '' then
|
||||
mp.commandv(command, event.value)
|
||||
menu:close()
|
||||
end
|
||||
end,
|
||||
{
|
||||
@@ -550,10 +713,10 @@ function open_open_file_menu()
|
||||
allowed_types = config.types.media,
|
||||
active_path = active_file,
|
||||
keep_open = true,
|
||||
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
|
||||
on_close = function() mp.unregister_event(handle_file_loaded) end,
|
||||
}
|
||||
)
|
||||
if menu then mp.register_event('file-loaded', handle_file_loaded) end
|
||||
end
|
||||
|
||||
---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
|
||||
@@ -581,12 +744,14 @@ function create_track_loader_menu_opener(opts)
|
||||
end
|
||||
end
|
||||
if not path then
|
||||
path = get_default_directory()
|
||||
path = options.default_directory
|
||||
end
|
||||
|
||||
local function handle_select(path) load_track(opts.prop, path) end
|
||||
local function handle_activate(event)
|
||||
if event.modifiers == '' then load_track(opts.prop, event.value) end
|
||||
end
|
||||
|
||||
open_file_navigation_menu(path, handle_select, {
|
||||
open_file_navigation_menu(path, handle_activate, {
|
||||
type = menu_type, title = title, allowed_types = opts.allowed_types,
|
||||
})
|
||||
end
|
||||
@@ -671,7 +836,11 @@ function open_subtitle_downloader()
|
||||
type = menu_type .. '-result',
|
||||
search_style = 'disabled',
|
||||
items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
|
||||
}, function() end)
|
||||
}, function(event)
|
||||
if event.type == 'key' and event.key == 'enter' then
|
||||
menu:close()
|
||||
end
|
||||
end)
|
||||
|
||||
local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
|
||||
'--file-id', tostring(data.id),
|
||||
@@ -822,10 +991,16 @@ function open_subtitle_downloader()
|
||||
title = t('enter query'),
|
||||
items = initial_items,
|
||||
search_style = 'palette',
|
||||
on_search = handle_search,
|
||||
on_search = 'callback',
|
||||
search_debounce = 'submit',
|
||||
search_suggestion = search_suggestion,
|
||||
},
|
||||
handle_select
|
||||
function(event)
|
||||
if event.type == 'activate' then
|
||||
handle_select(event.value)
|
||||
elseif event.type == 'search' then
|
||||
handle_search(event.query)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
@@ -17,6 +17,11 @@ function serialize_rgba(rgba)
|
||||
}
|
||||
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.
|
||||
---@param str string
|
||||
---@param char string
|
||||
@@ -217,6 +222,19 @@ function table_assign_props(target, source, props)
|
||||
return target
|
||||
end
|
||||
|
||||
-- Assign props from `source` to `target` that are not in `props` set.
|
||||
---@generic T: table<any, any>
|
||||
---@param target T
|
||||
---@param source T
|
||||
---@param props table<string, boolean>
|
||||
---@return T
|
||||
function table_assign_exclude(target, source, props)
|
||||
for key, value in pairs(source) do
|
||||
if not props[key] then target[key] = value end
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
-- `table_assign({}, input)` without loosing types :(
|
||||
---@generic T: table<any, any>
|
||||
---@param input T
|
||||
|
@@ -4,6 +4,8 @@
|
||||
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
|
||||
---@alias Circle {point: Point, r: number, window_drag?: boolean}
|
||||
---@alias Hitbox Rect|Circle
|
||||
---@alias Shortcut {id: string; key: string; modifiers: string; alt: boolean; ctrl: boolean; shift: boolean}
|
||||
---@alias ComplexBindingInfo {event: 'down' | 'repeat' | 'up' | 'press'; is_mouse: boolean; canceled: boolean; key_name?: string; key_text?: string;}
|
||||
|
||||
--- In place sorting of filenames
|
||||
---@param filenames string[]
|
||||
@@ -398,9 +400,40 @@ function has_any_extension(path, extensions)
|
||||
return false
|
||||
end
|
||||
|
||||
---@return string
|
||||
function get_default_directory()
|
||||
return mp.command_native({'expand-path', options.default_directory})
|
||||
---@param key string
|
||||
---@param modifiers? string
|
||||
---@return Shortcut
|
||||
function create_shortcut(key, modifiers)
|
||||
key = key:lower()
|
||||
|
||||
local id_parts, modifiers_set
|
||||
if modifiers then
|
||||
id_parts = split(modifiers:lower(), '+')
|
||||
table.sort(id_parts, function(a, b) return a < b end)
|
||||
modifiers_set = create_set(id_parts)
|
||||
modifiers = table.concat(id_parts, '+')
|
||||
else
|
||||
id_parts, modifiers, modifiers_set = {}, '', {}
|
||||
end
|
||||
id_parts[#id_parts + 1] = key
|
||||
|
||||
return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
|
||||
end
|
||||
|
||||
-- Executes mp command defined as a string or an itable, or does nothing if command is any other value.
|
||||
-- Returns boolean specifying if command was executed or not.
|
||||
---@param command string | string[] | nil | any
|
||||
---@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
|
||||
|
||||
-- Serializes path into its semantic parts.
|
||||
@@ -427,19 +460,18 @@ end
|
||||
-- Reads items in directory and splits it into directories and files tables.
|
||||
---@param path string
|
||||
---@param opts? {types?: string[], hidden?: boolean}
|
||||
---@return string[]|nil files
|
||||
---@return string[]|nil directories
|
||||
---@return string[] files
|
||||
---@return string[] directories
|
||||
---@return string|nil error
|
||||
function read_directory(path, opts)
|
||||
opts = opts or {}
|
||||
local items, error = utils.readdir(path, 'all')
|
||||
local files, directories = {}, {}
|
||||
|
||||
if not items then
|
||||
msg.error('Reading files from "' .. path .. '" failed: ' .. error)
|
||||
return nil, nil
|
||||
return files, directories, 'Reading directory "' .. path .. '" failed. Error: ' .. utils.to_string(error)
|
||||
end
|
||||
|
||||
local files, directories = {}, {}
|
||||
|
||||
for _, item in ipairs(items) do
|
||||
if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
|
||||
local info = utils.file_info(join_path(path, item))
|
||||
@@ -467,8 +499,8 @@ function get_adjacent_files(file_path, opts)
|
||||
opts = opts or {}
|
||||
local current_meta = serialize_path(file_path)
|
||||
if not current_meta then return end
|
||||
local files = read_directory(current_meta.dirname, {hidden = opts.hidden})
|
||||
if not files then return end
|
||||
local files, _dirs, error = read_directory(current_meta.dirname, {hidden = opts.hidden})
|
||||
if error then msg.error(error) return end
|
||||
sort_strings(files)
|
||||
local current_file_index
|
||||
local paths = {}
|
||||
@@ -782,18 +814,20 @@ end
|
||||
---@return {[string]: table}|table
|
||||
function find_active_keybindings(key)
|
||||
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
|
||||
if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
|
||||
not active[bind.key]
|
||||
or (active[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)
|
||||
not active_map[bind.key]
|
||||
or (active_map[bind.key].is_weak and not bind.is_weak)
|
||||
or (bind.is_weak == active_map[bind.key].is_weak and bind.priority > active_map[bind.key].priority)
|
||||
)
|
||||
then
|
||||
active[bind.key] = bind
|
||||
active_table[#active_table + 1] = bind
|
||||
active_map[bind.key] = bind
|
||||
end
|
||||
end
|
||||
return not key and active or active[key]
|
||||
return key and active_map[key] or active_table
|
||||
end
|
||||
|
||||
---@param type 'sub'|'audio'|'video'
|
||||
|
@@ -51,7 +51,7 @@ defaults = {
|
||||
top_bar = 'no-border',
|
||||
top_bar_size = 40,
|
||||
top_bar_persistency = '',
|
||||
top_bar_controls = true,
|
||||
top_bar_controls = 'right',
|
||||
top_bar_title = 'yes',
|
||||
top_bar_alt_title = '',
|
||||
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',
|
||||
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',
|
||||
playlist_types = 'm3u,m3u8,pls,url,cue',
|
||||
default_directory = '~/',
|
||||
show_hidden_files = 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
|
||||
options.destination_time = 'playtime-remaining'
|
||||
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
|
||||
if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
|
||||
|
||||
@@ -184,7 +188,11 @@ config = {
|
||||
audio = comma_split(options.audio_types),
|
||||
image = comma_split(options.image_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()
|
||||
---@type string[]
|
||||
local option_values = {}
|
||||
@@ -337,11 +345,13 @@ state = {
|
||||
alt_title = nil,
|
||||
time = nil, -- current media playback time
|
||||
speed = 1,
|
||||
---@type number|nil
|
||||
duration = nil, -- current media duration
|
||||
time_human = nil, -- current playback time in human format
|
||||
destination_time_human = nil, -- depends on options.destination_time
|
||||
pause = mp.get_property_native('pause'),
|
||||
chapters = {},
|
||||
---@type {index: number; title: string}|nil
|
||||
current_chapter = nil,
|
||||
chapter_ranges = {},
|
||||
border = mp.get_property_native('border'),
|
||||
@@ -373,6 +383,7 @@ state = {
|
||||
cache = nil,
|
||||
cache_buffering = 100,
|
||||
cache_underrun = false,
|
||||
cache_duration = nil,
|
||||
core_idle = false,
|
||||
eof_reached = false,
|
||||
render_delay = config.render_delay,
|
||||
@@ -386,6 +397,7 @@ state = {
|
||||
scale = 1,
|
||||
radius = 0,
|
||||
}
|
||||
buttons = require('lib/buttons')
|
||||
thumbnail = {width = 0, height = 0, disabled = false}
|
||||
external = {} -- Properties set by external scripts
|
||||
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)
|
||||
if serialized and serialized.dirname then
|
||||
local files = read_directory(serialized.dirname, {
|
||||
local files, _dirs, error = read_directory(serialized.dirname, {
|
||||
types = config.types.autoload,
|
||||
hidden = options.show_hidden_files,
|
||||
})
|
||||
|
||||
if not files then return end
|
||||
if error then
|
||||
msg.error(error)
|
||||
return
|
||||
end
|
||||
|
||||
sort_strings(files)
|
||||
if index < 0 then index = #files + index + 1 end
|
||||
|
||||
@@ -570,11 +586,18 @@ function observe_display_fps(name, fps)
|
||||
end
|
||||
|
||||
function select_current_chapter()
|
||||
local current_chapter_index = state.current_chapter and state.current_chapter.index
|
||||
local current_chapter
|
||||
if state.time and state.chapters then
|
||||
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
|
||||
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)
|
||||
if itable_has(config.top_bar_flash_on, 'chapter') then
|
||||
Elements:flash({'top_bar'})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[ STATE HOOKS ]]
|
||||
@@ -766,6 +789,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
||||
if cache_state then
|
||||
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_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
|
||||
else
|
||||
cached_ranges = {}
|
||||
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
|
||||
(state.cache == 'auto' and state.is_stream))) then
|
||||
if state.uncached_ranges then set_state('uncached_ranges', nil) end
|
||||
set_state('cache_duration', nil)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -886,11 +911,12 @@ bind_command('playlist', create_self_updating_menu_opener({
|
||||
end
|
||||
return items
|
||||
end,
|
||||
on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end,
|
||||
on_move_item = function(from, to)
|
||||
mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1))
|
||||
on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
|
||||
on_move = function(event)
|
||||
local from, to = event.from_index, event.to_index
|
||||
mp.commandv('playlist-move', tostring(from - 1), tostring(to - (to > from and 0 or 1)))
|
||||
end,
|
||||
on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end,
|
||||
on_remove = function(event) mp.commandv('playlist-remove', tostring(event.index - 1)) end,
|
||||
}))
|
||||
bind_command('chapters', create_self_updating_menu_opener({
|
||||
title = t('Chapters'),
|
||||
@@ -910,7 +936,7 @@ bind_command('chapters', create_self_updating_menu_opener({
|
||||
end
|
||||
return items
|
||||
end,
|
||||
on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
|
||||
on_activate = function(event) mp.commandv('set', 'chapter', tostring(event.value - 1)) end,
|
||||
}))
|
||||
bind_command('editions', create_self_updating_menu_opener({
|
||||
title = t('Editions'),
|
||||
@@ -930,7 +956,7 @@ bind_command('editions', create_self_updating_menu_opener({
|
||||
end
|
||||
return items
|
||||
end,
|
||||
on_select = function(id) mp.commandv('set', 'edition', id) end,
|
||||
on_activate = function(event) mp.commandv('set', 'edition', event.value) end,
|
||||
}))
|
||||
bind_command('show-in-directory', function()
|
||||
-- Ignore URLs
|
||||
@@ -1011,7 +1037,7 @@ bind_command('audio-device', create_self_updating_menu_opener({
|
||||
end
|
||||
return items
|
||||
end,
|
||||
on_select = function(name) mp.commandv('set', 'audio-device', name) end,
|
||||
on_activate = function(event) mp.commandv('set', 'audio-device', event.value) end,
|
||||
}))
|
||||
bind_command('open-config-directory', function()
|
||||
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
|
||||
@@ -1060,6 +1086,17 @@ mp.register_script_message('update-menu', function(json)
|
||||
if menu then menu:update(data) end
|
||||
end
|
||||
end)
|
||||
mp.register_script_message('select-menu-item', function(type, item_index, menu_id)
|
||||
local menu = Menu:is_open(type)
|
||||
local index = tonumber(item_index)
|
||||
if menu and index and not menu.mouse_nav then
|
||||
index = round(index)
|
||||
if index > 0 and index <= #menu.current.items then
|
||||
menu:select_index(index, menu_id)
|
||||
menu:scroll_to_index(index, menu_id, true)
|
||||
end
|
||||
end
|
||||
end)
|
||||
mp.register_script_message('close-menu', function(type)
|
||||
if Menu:is_open(type) then Menu:close() end
|
||||
end)
|
||||
|
Reference in New Issue
Block a user