25 Commits

Author SHA1 Message Date
tomasklaen
61c259b137 feat: default_directory option now supports {drives} on windows
On unix, this will switch to `/`.

closes #953
2024-08-28 20:05:21 +02:00
tomasklaen
e975429086 feat: display ignored bindings in keybinds menu
ref #901
2024-08-28 18:30:19 +02:00
tomasklaen
5f701436d1 feat: top_bar_flash_on option now supports chapter as an event
ref #478
2024-08-28 18:14:55 +02:00
Tomas Klaen
7f054a45f8 feat: menu lifecycle and actions API (#936) 2024-08-28 17:37:53 +02:00
tomasklaen
a0a608d451 feat: window controls now respect opacity.controls config
closes #949
2024-08-19 09:48:34 +02:00
dyphire
8346db433f fix: always read romanizations for configured languages (#948)
When I originally added this feature, I overlooked that this was actually different from UI localization.
It has different usage scenarios: UI main language and romanization files can be separated
2024-08-13 10:41:17 +02:00
Tomas Klaen
fe0d394435 feat: set-button API for scripts to add custom control bar buttons (#938)
* feat: `set-button` API for scripts to add custom control bar buttons

Adds `set-button <name> <data_json>` message external scripts can send to add or update buttons whose state, look, and click action is defined by `data_json`.

Users can then add this button into their controls bar with `button:<name>` syntax.

closes #935
2024-07-29 10:48:51 +02:00
christoph-heinrich
9fa7220da4 fix: use primary_click for chapter indicators (#932)
Listening to `primary_down`, but not listening to the `primary_up` event
leads to that `up` event getting forwarded. Listening to `primary_click`
instead avoids that problem.

Seeking chapters now happens on button up instead of button down, but
that shouldn't be a problem for anyone and makes their behavior
consistent with other clickable UI elements.

ref. https://github.com/tomasklaen/uosc/discussions/931
2024-07-12 22:44:48 +02:00
christoph-heinrich
c660db0743 feat: use loadlist when opening playlists (#929)
Opening a playlist with loadfile can lead to problems like autoload.lua
populating the playlist with other files that were in the same directory
as the playlist file.

To avoid that the command loadlist can be used to avoid having mpv treat
the file like any other, just to notice during demuxing that it is a
playlist.
2024-07-09 09:29:00 +02:00
christoph-heinrich
3bf774bf54 feat: show playlist files in the file navigation menu (#927)
So far the file navigation menu would only show media files and
directories, but opening playlist files via that menu is a valid use
case as well.

From a quick look at demux_playlist.c and a bit of testing, those seem
to be the only playlist formats mpv supports.
mpv can also open .txt files as playlists, but they need to be provided
via the --playlist=<path> argument and thus don't work here.

ref. https://github.com/tomasklaen/uosc/discussions/926
2024-07-01 23:07:58 +02:00
tomasklaen
1c38d4d3a7 fix: timeline_step wasn't being disabled when set to 0
closes #910
2024-06-03 09:22:48 +02:00
tomasklaen
08df4aa944 fix: menu item separators were being ignored
ref #901
2024-05-27 10:12:53 +02:00
christoph-heinrich
b01c6df787 fix: buffered_time_threshold check (#907)
a7136337c8 fixed the shown duration,
but the threshold check wasn't adjusted and therefore still didn't behave
the same as before 00737e1598.
2024-05-20 23:06:25 +02:00
tomasklaen
43dcb51eb9 feat: added additional selected menu item indicator
closes #853
2024-05-09 11:59:06 +02:00
tomasklaen
0e60272db1 tweak: platform agnostic window controls design 2024-05-08 11:05:13 +02:00
tomasklaen
2940352fad fix: keybinds menu not listing mpv-menu-plugin keyless syntax commands
ref #901
2024-05-07 17:13:10 +02:00
tomasklaen
d4b1343609 fix: keybinds menu list not skipping duplicate, disabled, and ignore keybinds
ref #901
2024-05-07 16:55:15 +02:00
dyphire
89360096c6 fix: display proper capitalization and selected index in track menus (#902)
mpv upstream commit mpv-player/mpv@ab3b174 has introduced support for BCP 47 language tags,
and now track lang has uppercase and lowercase content.

Also avoids showing the selection status of secondary subtitle in the subtitle menu at the same time.
2024-05-06 11:34:26 +02:00
tomasklaen
e15523a56e feat: keybinds menu now also includes keyless or overwritten bindings from input.conf
closes #901
2024-05-06 11:21:16 +02:00
tomasklaen
4c64fe9d52 fix: flash out animations always starting from max instead of current visibility value
closes #900
2024-05-06 10:12:57 +02:00
christoph-heinrich
7f03046223 feat: support aligning window controls to the left (#894) 2024-05-04 22:07:34 +02:00
christoph-heinrich
a7136337c8 fix: scale cache duration with speed again (#899)
fixes 00737e1598
2024-05-04 22:06:46 +02:00
christoph-heinrich
56b62bb0b2 tweak: same spacing between all top bar elements (#893)
There is no reason why that space should be any different to the one
between the playlist and the title
2024-05-01 16:21:43 +02:00
christoph-heinrich
00737e1598 refactor: use demuxer-cache-state/cache-duration property (#889)
It's used by both osc.lua and stats.lua and gives a more accurate
estimation of how much media is cached.
2024-04-23 14:41:13 +02:00
tomasklaen
e6a5fd981d fix: cycle button controls not working properly on floating point values like speed
closes #881
2024-04-07 13:27:46 +02:00
17 changed files with 1313 additions and 1036 deletions

291
README.md
View File

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

View File

@@ -43,6 +43,7 @@ progress_line_width=20
# {scale} - factor of controls_size, default: 0.3 # {scale} - factor of controls_size, default: 0.3
# `space` - fills all available space between previous and next item, useful to align items to the right # `space` - fills all available space between previous and next item, useful to align items to the right
# - multiple spaces divide the available space among themselves, which can be used for centering # - multiple spaces divide the available space among themselves, which can be used for centering
# `button:{name}` - button whose state, look, and click action are managed by external script
# Item visibility control: # Item visibility control:
# `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility # `<[!]{disposition1}[,[!]{dispositionN}]>` - optional prefix to control element's visibility
# - `{disposition}` can be one of: # - `{disposition}` can be one of:
@@ -109,7 +110,8 @@ menu_type_to_search=yes
# Can be: never, no-border, always # Can be: never, no-border, always
top_bar=no-border top_bar=no-border
top_bar_size=40 top_bar_size=40
top_bar_controls=yes # Can be: `no` (hide), left or right
top_bar_controls=right
# Can be: `no` (hide), `yes` (inherit title from mpv.conf), or a custom template string # Can be: `no` (hide), `yes` (inherit title from mpv.conf), or a custom template string
top_bar_title=yes top_bar_title=yes
# Template string to enable alternative top bar title. If alt title matches main title, # Template string to enable alternative top bar title. If alt title matches main title,
@@ -120,7 +122,7 @@ top_bar_alt_title=
# `toggle` => toggle the top bar title text between main and alt by clicking # `toggle` => toggle the top bar title text between main and alt by clicking
# the top bar, or calling `toggle-title` binding # the top bar, or calling `toggle-title` binding
top_bar_alt_title_place=below top_bar_alt_title_place=below
# Flash top bar when any of these file types is loaded. Available: audio,image,video # Flash top bar when any of these file types is loaded. Available: audio,video,image,chapter
top_bar_flash_on=video,audio top_bar_flash_on=video,audio
top_bar_persistency= top_bar_persistency=
@@ -191,7 +193,8 @@ video_types=3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg
audio_types=aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv audio_types=aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv
image_types=apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp image_types=apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp
subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt
# Default open-file menu directory playlist_types=m3u,m3u8,pls,url,cue
# Default open-file menu directory. Use `{drives}` to open drives menu on windows (defaults to `/` on unix).
default_directory=~/ default_directory=~/
# List hidden files when reading directories. Due to environment limitations, this currently only hides # List hidden files when reading directories. Due to environment limitations, this currently only hides
# files starting with a dot. Doesn't hide hidden files on windows (we have no way to tell they're hidden). # files starting with a dot. Doesn't hide hidden files on windows (we have no way to tell they're hidden).

View File

@@ -1,6 +1,6 @@
local Element = require('elements/Element') local Element = require('elements/Element')
---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string} ---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
---@class Button : Element ---@class Button : Element
local Button = class(Element) local Button = class(Element)
@@ -17,13 +17,15 @@ function Button:init(id, props)
self.badge = props.badge self.badge = props.badge
self.foreground = props.foreground or fg self.foreground = props.foreground or fg
self.background = props.background or bg self.background = props.background or bg
---@type fun() self.is_clickable = true
---@type fun()|nil
self.on_click = props.on_click self.on_click = props.on_click
Element.init(self, id, props) Element.init(self, id, props)
end end
function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
function Button:handle_cursor_click() function Button:handle_cursor_click()
if not self.on_click or not self.is_clickable then return end
-- We delay the callback to next tick, otherwise we are risking race -- We delay the callback to next tick, otherwise we are risking race
-- conditions as we are in the middle of event dispatching. -- conditions as we are in the middle of event dispatching.
-- For example, handler might add a menu to the end of the element stack, and that -- For example, handler might add a menu to the end of the element stack, and that
@@ -37,17 +39,20 @@ function Button:render()
cursor:zone('primary_click', self, function() self:handle_cursor_click() end) cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
local ass = assdraw.ass_new() local ass = assdraw.ass_new()
local is_clickable = self.is_clickable and self.on_click ~= nil
local is_hover = self.proximity_raw == 0 local is_hover = self.proximity_raw == 0
local is_hover_or_active = is_hover or self.active
local foreground = self.active and self.background or self.foreground local foreground = self.active and self.background or self.foreground
local background = self.active and self.foreground or self.background local background = self.active and self.foreground or self.background
local background_opacity = self.active and 1 or config.opacity.controls
if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
-- Background -- Background
if is_hover_or_active or config.opacity.controls > 0 then if background_opacity > 0 then
ass:rect(self.ax, self.ay, self.bx, self.by, { ass:rect(self.ax, self.ay, self.bx, self.by, {
color = (self.active or not is_hover) and background or foreground, color = (self.active or not is_hover) and background or foreground,
radius = state.radius, radius = state.radius,
opacity = visibility * (self.active and 1 or (is_hover and 0.3 or config.opacity.controls)), opacity = visibility * background_opacity,
}) })
end end

View File

@@ -1,6 +1,7 @@
local Element = require('elements/Element') local Element = require('elements/Element')
local Button = require('elements/Button') local Button = require('elements/Button')
local CycleButton = require('elements/CycleButton') local CycleButton = require('elements/CycleButton')
local ManagedButton = require('elements/ManagedButton')
local Speed = require('elements/Speed') local Speed = require('elements/Speed')
-- sizing: -- sizing:
@@ -163,6 +164,19 @@ function Controls:init_options()
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
if badge then self:register_badge_updater(badge, element) end if badge then self:register_badge_updater(badge, element) end
end end
elseif kind == 'button' then
if #params ~= 1 then
mp.error(string.format(
'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
))
else
local element = ManagedButton:new('control_' .. i, {
name = params[1],
render_order = self.render_order,
anchor_id = 'controls',
})
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
end
elseif kind == 'speed' then elseif kind == 'speed' then
if not Elements.speed then if not Elements.speed then
local element = Speed:new({anchor_id = 'controls', render_order = self.render_order}) local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})

View File

@@ -35,6 +35,12 @@ function CycleButton:init(id, props)
end end
local function handle_change(name, value) local function handle_change(name, value)
-- Removes unnecessary floating point digits from values like `2.00000`.
-- This happens when observing properties like `speed`.
if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
value = tonumber(value)
end
value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '') value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
local index = itable_find(self.states, function(state) return state.value == value end) local index = itable_find(self.states, function(state) return state.value == value end)
self.current_state_index = index or 1 self.current_state_index = index or 1

View File

@@ -35,7 +35,7 @@ function Element:init(id, props)
local function getTo() return self.proximity end local function getTo() return self.proximity end
local function onTweenEnd() self.forced_visibility = nil end local function onTweenEnd() self.forced_visibility = nil end
if self.enabled then if self.enabled then
self:tween_property('forced_visibility', 1, getTo, onTweenEnd) self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
else else
onTweenEnd() onTweenEnd()
end end

View 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

View File

@@ -163,8 +163,6 @@ function Timeline:on_global_mouse_move()
end end
end end
end end
function Timeline:handle_wheel_up() mp.commandv('seek', options.timeline_step) end
function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) end
function Timeline:render() function Timeline:render()
if self.size == 0 then return end if self.size == 0 then return end
@@ -186,8 +184,10 @@ function Timeline:render()
self:handle_cursor_down() self:handle_cursor_down()
cursor:once('primary_up', function() self:handle_cursor_up() end) cursor:once('primary_up', function() self:handle_cursor_up() end)
end) end)
cursor:zone('wheel_down', self, function() self:handle_wheel_down() end) if options.timeline_step ~= 0 then
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end) cursor:zone('wheel_down', self, function() mp.commandv('seek', -options.timeline_step) end)
cursor:zone('wheel_up', self, function() mp.commandv('seek', options.timeline_step) end)
end
end end
local ass = assdraw.ass_new() local ass = assdraw.ass_new()
@@ -251,15 +251,11 @@ function Timeline:render()
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position}) ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
-- Uncached ranges -- Uncached ranges
local buffered_playtime = nil
if state.uncached_ranges then if state.uncached_ranges then
local opts = {size = 80, anchor_y = fby} local opts = {size = 80, anchor_y = fby}
local texture_char = visibility > 0 and 'b' or 'a' local texture_char = visibility > 0 and 'b' or 'a'
local offset = opts.size / (visibility > 0 and 24 or 28) local offset = opts.size / (visibility > 0 and 24 or 28)
for _, range in ipairs(state.uncached_ranges) do for _, range in ipairs(state.uncached_ranges) do
if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then
buffered_playtime = (range[1] - state.time) / state.speed
end
if options.timeline_cache then if options.timeline_cache then
local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1])) local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2])) local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
@@ -321,7 +317,7 @@ function Timeline:render()
if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered} local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
if visibility > 0 then if visibility > 0 then
cursor:zone('primary_down', circle, function() cursor:zone('primary_click', circle, function()
mp.commandv('seek', chapter.time, 'absolute+exact') mp.commandv('seek', chapter.time, 'absolute+exact')
end) end)
end end
@@ -377,14 +373,15 @@ function Timeline:render()
if text_opacity > 0 then if text_opacity > 0 then
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale} local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
-- Upcoming cache time -- Upcoming cache time
if buffered_playtime and options.buffered_time_threshold > 0 local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
and buffered_playtime < options.buffered_time_threshold then if cache_duration and options.buffered_time_threshold > 0
and cache_duration < options.buffered_time_threshold then
local margin = 5 * state.scale local margin = 5 * state.scale
local x, align = fbx + margin, 4 local x, align = fbx + margin, 4
local cache_opts = { local cache_opts = {
size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale, size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
} }
local human = round(math.max(buffered_playtime, 0)) .. 's' local human = round(cache_duration) .. 's'
local width = text_width(human, cache_opts) local width = text_width(human, cache_opts)
local time_width = timestamp_width(state.time_human, time_opts) local time_width = timestamp_width(state.time_human, time_opts)
local time_width_end = timestamp_width(state.destination_time_human, time_opts) local time_width_end = timestamp_width(state.destination_time_human, time_opts)

View File

@@ -1,46 +1,6 @@
local Element = require('elements/Element') local Element = require('elements/Element')
---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()} ---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
---@class TopBarButton : Element
local TopBarButton = class(Element)
---@param id string
---@param props TopBarButtonProps
function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
function TopBarButton:init(id, props)
Element.init(self, id, props)
self.anchor_id = 'top_bar'
self.icon = props.icon
self.background = props.background
self.command = props.command
end
function TopBarButton:handle_click()
mp.command(type(self.command) == 'function' and self.command() or self.command)
end
function TopBarButton:render()
local visibility = self:get_visibility()
if visibility <= 0 then return end
local ass = assdraw.ass_new()
-- Background on hover
if self.proximity_raw == 0 then
ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
end
cursor:zone('primary_click', self, function() self:handle_click() end)
local width, height = self.bx - self.ax, self.by - self.ay
local icon_size = math.min(width, height) * 0.5
ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
opacity = visibility, border = options.text_border * state.scale,
})
return ass
end
--[[ TopBar ]]
---@class TopBar : Element ---@class TopBar : Element
local TopBar = class(Element) local TopBar = class(Element)
@@ -49,48 +9,30 @@ function TopBar:new() return Class.new(self) --[[@as TopBar]] end
function TopBar:init() function TopBar:init()
Element.init(self, 'top_bar', {render_order = 4}) Element.init(self, 'top_bar', {render_order = 4})
self.size = 0 self.size = 0
self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1 self.icon_size, self.font_size, self.title_by = 1, 1, 1
self.show_alt_title = false self.show_alt_title = false
self.main_title, self.alt_title = nil, nil self.main_title, self.alt_title = nil, nil
local function get_maximized_command() local function maximized_command()
if state.platform == 'windows' then if state.platform == 'windows' then
return state.border mp.command(state.border
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized') and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
or 'set window-maximized no;cycle fullscreen' or 'set window-maximized no;cycle fullscreen')
else
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
end end
return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
end end
-- Order aligns from right to left local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
self.buttons = { local max = {icon = 'crop_square', command = maximized_command}
TopBarButton:new('tb_close', { local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order, self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
}),
TopBarButton:new('tb_max', {
icon = 'crop_square',
background = '222222',
command = get_maximized_command,
render_order = self.render_order,
}),
TopBarButton:new('tb_min', {
icon = 'minimize',
background = '222222',
command = 'cycle window-minimized',
render_order = self.render_order,
}),
}
self:decide_titles() self:decide_titles()
self:decide_enabled() self:decide_enabled()
self:update_dimensions() self:update_dimensions()
end end
function TopBar:destroy()
for _, button in ipairs(self.buttons) do button:destroy() end
Element.destroy(self)
end
function TopBar:decide_enabled() function TopBar:decide_enabled()
if options.top_bar == 'no-border' then if options.top_bar == 'no-border' then
self.enabled = not state.border or state.title_bar == false or state.fullscreen self.enabled = not state.border or state.title_bar == false or state.fullscreen
@@ -98,9 +40,6 @@ function TopBar:decide_enabled()
self.enabled = options.top_bar == 'always' self.enabled = options.top_bar == 'always'
end end
self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist) self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
for _, element in ipairs(self.buttons) do
element.enabled = self.enabled and options.top_bar_controls
end
end end
function TopBar:decide_titles() function TopBar:decide_titles()
@@ -136,22 +75,12 @@ end
function TopBar:update_dimensions() function TopBar:update_dimensions()
self.size = round(options.top_bar_size * state.scale) self.size = round(options.top_bar_size * state.scale)
self.icon_size = round(self.size * 0.5) self.icon_size = round(self.size * 0.5)
self.spacing = math.ceil(self.size * 0.25) self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
self.button_width = round(self.size * 1.15)
local window_border_size = Elements:v('window_border', 'size', 0) local window_border_size = Elements:v('window_border', 'size', 0)
self.ax = window_border_size
self.ay = window_border_size self.ay = window_border_size
self.bx = display.width - window_border_size self.bx = display.width - window_border_size
self.by = self.size + window_border_size self.by = self.size + window_border_size
self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
self.ax = (options.top_bar_title ~= 'no' or state.has_playlist) and window_border_size or self.title_bx
local button_bx = self.bx
for _, element in pairs(self.buttons) do
element.ax, element.bx = button_bx - self.button_width, button_bx
element.ay, element.by = self.ay, self.by
button_bx = button_bx - self.button_width
end
end end
function TopBar:toggle_title() function TopBar:toggle_title()
@@ -199,15 +128,54 @@ function TopBar:render()
local visibility = self:get_visibility() local visibility = self:get_visibility()
if visibility <= 0 then return end if visibility <= 0 then return end
local ass = assdraw.ass_new() local ass = assdraw.ass_new()
local ax, bx = self.ax, self.bx
local margin = math.floor((self.size - self.font_size) / 4)
-- Window controls
if options.top_bar_controls then
local is_left, button_ax = options.top_bar_controls == 'left', 0
if is_left then
button_ax = ax
ax = self.size * #self.buttons
else
button_ax = bx - self.size * #self.buttons
bx = button_ax
end
for _, button in ipairs(self.buttons) do
local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
local opacity = is_hover and 1 or config.opacity.controls
local button_fg = is_hover and (button.hover_fg or bg) or fg
local button_bg = is_hover and (button.hover_bg or fg) or bg
cursor:zone('primary_click', rect, button.command)
local bg_size = self.size - margin
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
color = button_bg, opacity = visibility * opacity, radius = state.radius,
})
ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
color = button_fg,
border_color = button_bg,
opacity = visibility,
border = options.text_border * state.scale,
})
button_ax = button_ax + self.size
end
end
-- Window title -- Window title
if state.title or state.has_playlist then if state.title or state.has_playlist then
local bg_margin = math.floor((self.size - self.font_size) / 4)
local padding = self.font_size / 2 local padding = self.font_size / 2
local spacing = 1 local spacing = 1
local title_ax = self.ax + bg_margin local left_aligned = options.top_bar_controls == 'left'
local title_ay = self.ay + bg_margin local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
local max_bx = self.title_bx - self.spacing
-- Playlist position -- Playlist position
if state.has_playlist then if state.has_playlist then
@@ -215,11 +183,13 @@ function TopBar:render()
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/' local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
.. state.playlist_count .. state.playlist_count
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility} local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
local rect_width = round(text_width(text, opts) + padding * 2)
local ax = left_aligned and title_bx - rect_width or title_ax
local rect = { local rect = {
ax = title_ax, ax = ax,
ay = title_ay, ay = title_ay,
bx = round(title_ax + text_width(text, opts) + padding * 2), bx = ax + rect_width,
by = self.by - bg_margin, by = self.by - margin,
} }
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0 local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
and 1 or config.opacity.playlist_position and 1 or config.opacity.playlist_position
@@ -229,14 +199,14 @@ function TopBar:render()
}) })
end end
ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts) ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
title_ax = rect.bx + bg_margin if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
-- Click action -- Click action
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end) cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
end end
-- Skip rendering titles if there's not enough horizontal space -- Skip rendering titles if there's not enough horizontal space
if max_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
-- Main title -- Main title
local main_title = self.show_alt_title and self.alt_title or self.main_title local main_title = self.show_alt_title and self.alt_title or self.main_title
if main_title then if main_title then
@@ -247,11 +217,13 @@ function TopBar:render()
opacity = visibility, opacity = visibility,
border = options.text_border * state.scale, border = options.text_border * state.scale,
border_color = bg, border_color = bg,
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by), clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
} }
local bx = round(math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2)) local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
local by = self.by - bg_margin local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
local title_rect = {ax = title_ax, ay = title_ay, bx = bx, by = by} local ax = left_aligned and title_bx - rect_width or title_ax
local by = self.by - margin
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
if options.top_bar_alt_title_place == 'toggle' then if options.top_bar_alt_title_place == 'toggle' then
cursor:zone('primary_click', title_rect, function() self:toggle_title() end) cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
@@ -260,7 +232,9 @@ function TopBar:render()
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, { ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius, color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
}) })
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, main_title, opts) local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and title_rect.bx - padding or ax + padding
ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
title_ay = by + spacing title_ay = by + spacing
end end
@@ -277,12 +251,17 @@ function TopBar:render()
border_color = bg, border_color = bg,
opacity = visibility, opacity = visibility,
} }
local bx = round(math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2)) local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
local ax = left_aligned and title_bx - rect_width or title_ax
local bx = ax + rect_width
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by) opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
ass:rect(title_ax, title_ay, bx, by, { ass:rect(ax, title_ay, bx, by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius, color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
}) })
ass:txt(title_ax + padding, title_ay + height / 2, 4, self.alt_title, opts) local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and bx - padding or ax + padding
ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
title_ay = by + spacing title_ay = by + spacing
end end
@@ -291,7 +270,8 @@ function TopBar:render()
local padding_half = round(padding / 2) local padding_half = round(padding / 2)
local font_size = self.font_size * 0.8 local font_size = self.font_size * 0.8
local height = font_size * 1.3 local height = font_size * 1.3
local text = '' .. state.current_chapter.index .. ': ' .. state.current_chapter.title local prefix, postfix = left_aligned and '' or '', left_aligned and ' ' or ''
local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
local next_chapter = state.chapters[state.current_chapter.index + 1] local next_chapter = state.chapters[state.current_chapter.index + 1]
local chapter_end = next_chapter and next_chapter.time or state.duration or 0 local chapter_end = next_chapter and next_chapter.time or state.duration or 0
local remaining_time = ((state.time or 0) - chapter_end) / local remaining_time = ((state.time or 0) - chapter_end) /
@@ -310,26 +290,29 @@ function TopBar:render()
local remaining_box_width = remaining_width + padding_half * 2 local remaining_box_width = remaining_width + padding_half * 2
-- Title -- Title
local max_bx = title_bx - remaining_box_width - spacing
local rect_ideal_width = round(text_width(text, opts) + padding * 2)
local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
local ax = left_aligned and title_bx - rect_width or title_ax
local rect = { local rect = {
ax = title_ax, ax = ax,
ay = title_ay, ay = title_ay,
bx = round(math.min( bx = ax + rect_width,
max_bx - remaining_box_width - spacing,
title_ax + text_width(text, opts) + padding * 2
)),
by = title_ay + height, by = title_ay + height,
} }
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by) opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, { ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
color = bg, opacity = visibility * config.opacity.title, radius = state.radius, color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
}) })
ass:txt(rect.ax + padding, rect.ay + height / 2, 4, text, opts) local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
local x = align == 6 and rect.bx - padding or rect.ax + padding
ass:txt(x, rect.ay + height / 2, align, text, opts)
-- Click action -- Click action
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end) cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
-- Time -- Time
rect.ax = rect.bx + spacing rect.ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
rect.bx = rect.ax + remaining_box_width rect.bx = rect.ax + remaining_box_width
opts.clip = nil opts.clip = nil
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, { ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {

View File

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

69
src/uosc/lib/buttons.lua Normal file
View 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

View File

@@ -4,13 +4,8 @@ local char_dir = mp.get_script_directory() .. '/char-conv/'
local data = {} local data = {}
local languages = get_languages() local languages = get_languages()
for i = #languages, 1, -1 do for _, lang in ipairs(languages) do
lang = languages[i]
if (lang == 'en') then
data = {}
else
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json')) table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
end
end end
local romanization = {} local romanization = {}

View File

@@ -1,6 +1,9 @@
---@param data MenuData ---@param data MenuData
---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]} ---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
function open_command_menu(data, opts) function open_command_menu(data, opts)
opts = opts or {}
local menu
local function run_command(command) local function run_command(command)
if type(command) == 'string' then if type(command) == 'string' then
mp.command(command) mp.command(command)
@@ -9,14 +12,21 @@ function open_command_menu(data, opts)
mp.commandv(unpack(command)) mp.commandv(unpack(command))
end end
end end
---@type MenuOptions
local menu_opts = {} local function callback(event)
if opts then if type(menu.root.callback) == 'table' then
menu_opts.mouse_nav = opts.mouse_nav ---@diagnostic disable-next-line: deprecated
if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end mp.commandv(unpack(itable_join({'script-message-to'}, menu.root.callback, {utils.format_json(event)})))
elseif event.type == 'activate' then
run_command(event.value)
menu:close()
end end
local menu = Menu:open(data, run_command, menu_opts) end
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
---@type MenuOptions
local menu_opts = table_assign_props({}, opts, {'mouse_nav'})
menu = Menu:open(data, callback, menu_opts)
if opts.submenu then menu:activate_menu(opts.submenu) end
return menu return menu
end end
@@ -29,7 +39,8 @@ function toggle_menu_with_items(opts)
end end
end end
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} ---@alias EventRemove {type: 'remove' | 'delete', index: number; value: any; menu_id: string;}
---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[];on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: EventRemove); on_delete?: fun(event: EventRemove)}
function create_self_updating_menu_opener(opts) function create_self_updating_menu_opener(opts)
return function() return function()
if Menu:is_open(opts.type) then if Menu:is_open(opts.type) then
@@ -64,30 +75,88 @@ function create_self_updating_menu_opener(opts)
local initial_items, selected_index = opts.serializer(list, active) local initial_items, selected_index = opts.serializer(list, active)
---@type MenuAction[]
local actions = opts.actions or {}
if opts.on_move then
actions[#actions + 1] = {name = 'move_up', icon = 'arrow_upward', label = t('Move up')}
actions[#actions + 1] = {name = 'move_down', icon = 'arrow_downward', label = t('Move down')}
end
if opts.on_remove or opts.on_delete then
local label = opts.on_remove and t('Remove') or t('Delete')
if opts.on_remove and opts.on_delete then label = t('Remove (ctrl to delete)') end
actions[#actions + 1] = {name = 'remove', icon = 'delete', label = label}
end
function remove_or_delete(index, value, menu_id, modifiers)
if opts.on_remove and opts.on_delete then
local method = modifiers == 'ctrl' and 'delete' or 'remove'
local handler = method == 'delete' and opts.on_delete or opts.on_remove
if handler then
handler({type = method, value = value, index = index, menu_id = menu_id})
end
elseif opts.on_remove or opts.on_delete then
local method = opts.on_delete and 'delete' or 'remove'
local handler = opts.on_delete or opts.on_remove
if handler then
handler({type = method, value = value, index = index, menu_id = menu_id})
end
end
end
-- Items and active_index are set in the handle_prop_change callback, since adding -- Items and active_index are set in the handle_prop_change callback, since adding
-- a property observer triggers its handler immediately, we just let that initialize the items. -- a property observer triggers its handler immediately, we just let that initialize the items.
menu = Menu:open( menu = Menu:open({
{
type = opts.type, type = opts.type,
title = opts.title, title = opts.title,
items = initial_items, items = initial_items,
actions = actions,
selected_index = selected_index, selected_index = selected_index,
on_paste = opts.on_paste, on_move = opts.on_move and 'callback' or nil,
}, on_close = 'callback',
opts.on_select, { }, function(event)
on_open = function() if event.type == 'activate' then
if (event.action == 'move_up' or event.action == 'move_down') and opts.on_move then
local to_index = event.index + (event.action == 'move_up' and -1 or 1)
if to_index > 1 and to_index <= #menu.current.items then
opts.on_move({
type = 'move',
from_index = event.index,
to_index = to_index,
menu_id = menu.current.id,
})
menu:select_index(to_index)
menu:scroll_to_index(to_index, nil, true)
end
elseif event.action == 'remove' and (opts.on_remove or opts.on_delete) then
remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
elseif itable_has({'', 'shift'}, event.modifiers) then
opts.on_activate(event --[[@as MenuEventActivate]])
if event.modifiers == 'shift' then menu:close() end
end
elseif event.type == 'key' then
if event.id == 'enter' then
menu:close()
elseif event.key == 'del' then
if itable_has({'', 'ctrl'}, event.modifiers) then
remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
end
end
elseif event.type == 'paste' and opts.on_paste then
opts.on_paste(event --[[@as MenuEventPaste]])
elseif event.type == 'close' then
mp.unobserve_property(handle_list_prop_change)
mp.unobserve_property(handle_active_prop_change)
menu:close()
elseif event.type == 'move' and opts.on_move then
opts.on_move(event --[[@as MenuEventMove]])
elseif event.type == 'remove' and opts.on_move then
end
end)
mp.observe_property(opts.list_prop, 'native', handle_list_prop_change) mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
if opts.active_prop then if opts.active_prop then
mp.observe_property(opts.active_prop, 'native', handle_active_prop_change) mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
end end
end,
on_close = function()
mp.unobserve_property(handle_list_prop_change)
mp.unobserve_property(handle_active_prop_change)
end,
on_move_item = opts.on_move_item,
on_delete_item = opts.on_delete_item,
})
end end
end end
@@ -95,20 +164,23 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
local function serialize_tracklist(tracklist) local function serialize_tracklist(tracklist)
local items = {} local items = {}
if download_command then
items[#items + 1] = {
title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
}
end
if load_command then if load_command then
items[#items + 1] = { items[#items + 1] = {
title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}', title = t('Load'),
bold = true,
italic = true,
hint = t('open file'),
value = '{load}',
actions = download_command
and {{name = 'download', icon = 'language', label = t('Search online')}}
or nil,
} }
end end
if #items > 0 then if #items > 0 then
items[#items].separator = true items[#items].separator = true
end end
local track_prop_index = tonumber(mp.get_property(track_prop))
local first_item_index = #items + 1 local first_item_index = #items + 1
local active_index = nil local active_index = nil
local disabled_item = nil local disabled_item = nil
@@ -126,9 +198,10 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
for _, track in ipairs(tracklist) do for _, track in ipairs(tracklist) do
if track.type == track_type then if track.type == track_type then
local hint_values = {} local hint_values = {}
local track_selected = track.selected and track.id == track_prop_index
local function h(value) hint_values[#hint_values + 1] = value end local function h(value) hint_values[#hint_values + 1] = value end
if track.lang then h(track.lang:upper()) end if track.lang then h(track.lang) end
if track['demux-h'] then if track['demux-h'] then
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p')) h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
end end
@@ -148,10 +221,10 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
title = (track.title and track.title or t('Track %s', track.id)), title = (track.title and track.title or t('Track %s', track.id)),
hint = table.concat(hint_values, ', '), hint = table.concat(hint_values, ', '),
value = track.id, value = track.id,
active = track.selected, active = track_selected,
} }
if track.selected then if track_selected then
if disabled_item then disabled_item.active = false end if disabled_item then disabled_item.active = false end
active_index = #items active_index = #items
end end
@@ -161,16 +234,15 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
return items, active_index or first_item_index return items, active_index or first_item_index
end end
local function handle_select(value) ---@param event MenuEventActivate
if value == '{download}' then local function handle_activate(event)
mp.command(download_command) if event.value == '{load}' then
elseif value == '{load}' then mp.command(event.action == 'download' and download_command or load_command)
mp.command(load_command)
else else
mp.commandv('set', track_prop, value and value or 'no') mp.commandv('set', track_prop, event.value and event.value or 'no')
-- If subtitle track was selected, assume the user also wants to see it -- If subtitle track was selected, assume the user also wants to see it
if value and track_type == 'sub' then if event.value and track_type == 'sub' then
mp.commandv('set', 'sub-visibility', 'yes') mp.commandv('set', 'sub-visibility', 'yes')
end end
end end
@@ -181,132 +253,40 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_
type = track_type, type = track_type,
list_prop = 'track-list', list_prop = 'track-list',
serializer = serialize_tracklist, serializer = serialize_tracklist,
on_select = handle_select, on_activate = handle_activate,
on_paste = function(path) load_track(track_type, path) end, on_paste = function(event) load_track(track_type, event.value) end,
}) })
end end
---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()} ---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_close?: fun()}
-- Opens a file navigation menu with items inside `directory_path`. -- Opens a file navigation menu with items inside `directory_path`.
---@param directory_path string ---@param directory_path string
---@param handle_select fun(path: string, mods: Modifiers): nil ---@param handle_activate fun(event: MenuEventActivate)
---@param opts NavigationMenuOptions ---@param opts NavigationMenuOptions
function open_file_navigation_menu(directory_path, handle_select, opts) function open_file_navigation_menu(directory_path, handle_activate, opts)
directory = serialize_path(normalize_path(directory_path)) if directory_path == '{drives}' then
opts = opts or {} if state.platform ~= 'windows' then directory_path = '/' end
if not directory then
msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
return
end
local files, directories = read_directory(directory.path, {
types = opts.allowed_types,
hidden = options.show_hidden_files,
})
local is_root = not directory.dirname
local path_separator = path_separator(directory.path)
if not files or not directories then return end
sort_strings(directories)
sort_strings(files)
-- Pre-populate items with parent directory selector if not at root
-- Each item value is a serialized path table it points to.
local items = {}
if is_root then
if state.platform == 'windows' then
items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true}
end
else else
items[#items + 1] = {title = '..', hint = t('parent dir'), value = directory.dirname, separator = true} directory_path = normalize_path(mp.command_native({'expand-path', directory_path}))
end end
local back_path = items[#items] and items[#items].value
local selected_index = #items + 1
for _, dir in ipairs(directories) do
items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
end
for _, file in ipairs(files) do
items[#items + 1] = {title = file, value = join_path(directory.path, file)}
end
for index, item in ipairs(items) do
if not item.value.is_to_parent and opts.active_path == item.value then
item.active = true
if not opts.selected_path then selected_index = index end
end
if opts.selected_path == item.value then selected_index = index end
end
---@type MenuCallback
local function open_path(path, meta)
local is_drives = path == '{drives}'
local is_to_parent = is_drives or #path < #directory_path
local inheritable_options = {
type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
keep_open = opts.keep_open,
}
if is_drives then
open_drives_menu(function(drive_path)
open_file_navigation_menu(drive_path, handle_select, inheritable_options)
end, {
type = inheritable_options.type,
title = inheritable_options.title,
selected_path = directory.path,
on_open = opts.on_open,
on_close = opts.on_close,
})
return
end
local info, error = utils.file_info(path)
if not info then
msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
return
end
if info.is_dir and not meta.modifiers.alt and not meta.modifiers.ctrl then
-- Preselect directory we are coming from
if is_to_parent then
inheritable_options.selected_path = directory.path
end
open_file_navigation_menu(path, handle_select, inheritable_options)
else
handle_select(path, meta.modifiers)
end
end
local function handle_back()
if back_path then open_path(back_path, {modifiers = {}}) end
end
local menu_data = {
type = opts.type,
title = opts.title or directory.basename .. path_separator,
items = items,
keep_open = opts.keep_open,
selected_index = selected_index,
}
local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
return Menu:open(menu_data, open_path, menu_options)
end
-- Opens a file navigation menu with Windows drives as items.
---@param handle_select fun(path: string): nil
---@param opts? NavigationMenuOptions
function open_drives_menu(handle_select, opts)
opts = opts or {} opts = opts or {}
---@type string|nil
local current_directory = nil
---@type Menu
local menu
---@type string | nil
local back_path
local separator = path_separator(directory_path)
---@param path string Can be path to a directory, or special string `'{drives}'` to get windows drives items.
---@param selected_path? string Marks item with this path as active.
---@return MenuStackValue[] menu_items
---@return number selected_index
---@return string|nil error
local function serialize_items(path, selected_path)
if path == '{drives}' then
local process = mp.command_native({ local process = mp.command_native({
name = 'subprocess', name = 'subprocess',
capture_stdout = true, capture_stdout = true,
@@ -323,24 +303,162 @@ function open_drives_menu(handle_select, opts)
items[#items + 1] = { items[#items + 1] = {
title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path, title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
} }
if opts.selected_path == drive_path then selected_index = #items end if selected_path == drive_path then selected_index = #items end
end end
end end
else else
msg.error(process.stderr) return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr)
end
return items, selected_index
end end
return Menu:open( local serialized = serialize_path(path)
{type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index}, if not serialized then
handle_select return {}, 0, 'Couldn\'t serialize path "' .. path .. '.'
) end
local files, directories, error = read_directory(serialized.path, {
types = opts.allowed_types,
hidden = options.show_hidden_files,
})
if error then
return {}, 1, error
end
local is_root = not serialized.dirname
if not files or not directories then return {}, 0 end
sort_strings(directories)
sort_strings(files)
-- Pre-populate items with parent directory selector if not at root
-- Each item value is a serialized path table it points to.
local items = {}
if is_root then
if state.platform == 'windows' then
items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true}
end
else
items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true}
end
back_path = items[#items] and items[#items].value
local selected_index = #items + 1
for _, dir in ipairs(directories) do
items[#items + 1] = {title = dir, value = join_path(path, dir), hint = separator}
end
for _, file in ipairs(files) do
items[#items + 1] = {title = file, value = join_path(path, file)}
end
for index, item in ipairs(items) do
if not item.is_to_parent then
if opts.active_path == item.value then
item.active = true
if not selected_path then selected_index = index end
end
if selected_path == item.value then selected_index = index end
end
end
return items, selected_index
end
local menu_data = {
type = opts.type,
title = opts.title or '',
items = {},
on_close = opts.on_close and 'callback' or nil,
}
---@param path string
local function open_directory(path)
local items, selected_index, error = serialize_items(path, current_directory)
if error then
msg.error(error)
items = {{title = 'Something went wrong. See console for errors.', selectable = false, muted = true}}
end
local title = opts.title
if not title then
if path == '{drives}' then
title = 'Drives'
else
local serialized = serialize_path(path)
title = serialized and serialized.basename .. separator or '??'
end
end
current_directory = path
menu_data.title = title
menu_data.items = items
menu:search_cancel()
menu:update(menu_data)
menu:select_index(selected_index)
menu:scroll_to_index(selected_index, nil, true)
end
local function close()
menu:close()
if opts.on_close then opts.on_close() end
end
---@param event MenuEventActivate
local function activate(event)
local path = event.value
local is_drives = path == '{drives}'
if is_drives then
open_directory(path)
return
end
local info, error = utils.file_info(path)
if not info then
msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
return
end
if info.is_dir and event.modifiers == '' then
open_directory(path)
else
handle_activate(event)
if not opts.keep_open then close() end
end
end
menu = Menu:open(menu_data, function(event)
if event.type == 'activate' then
activate(event --[[@as MenuEventActivate]])
elseif event.type == 'back' then
if back_path then open_directory(back_path) end
elseif event.type == 'close' then
close()
end
end)
open_directory(directory_path)
return menu
end end
-- On demand menu items loading -- On demand menu items loading
do do
local items = nil ---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil
function get_menu_items() local all_user_bindings = nil
if items then return items end ---@type MenuStackItem[]|nil
local menu_items = nil
local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end
-- Returns all relevant bindings from `input.conf`, even if they are overwritten
-- (same key bound to something else later) or have no keys (uosc menu items).
function get_all_user_bindings()
if all_user_bindings then return all_user_bindings end
all_user_bindings = {}
local input_conf_property = mp.get_property_native('input-conf') local input_conf_property = mp.get_property_native('input-conf')
local input_conf_iterator local input_conf_iterator
@@ -359,23 +477,53 @@ do
-- File doesn't exist -- File doesn't exist
if not input_conf_meta or not input_conf_meta.is_file then if not input_conf_meta or not input_conf_meta.is_file then
items = create_default_menu_items() menu_items = create_default_menu_items()
return items return menu_items, all_user_bindings
end end
input_conf_iterator = io.lines(input_conf_path) input_conf_iterator = io.lines(input_conf_path)
end end
for line in input_conf_iterator do
local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$')
local is_commented_out = key and key:sub(1, 1) == '#'
if comment and #comment > 0 then comment = comment:sub(2) end
if command then command = trim(command) end
local is_menu_item = comment and is_uosc_menu_comment(comment)
if key
-- Filter out stuff like `#F2`, which is clearly intended to be disabled
and not (is_commented_out and #key > 1)
-- Filter out comments that are not uosc menu items
and (not is_commented_out or is_menu_item) then
all_user_bindings[#all_user_bindings + 1] = {
key = key,
cmd = command,
comment = comment or '',
is_menu_item = is_menu_item,
}
end
end
return all_user_bindings
end
function get_menu_items()
if menu_items then return menu_items end
local all_user_bindings = get_all_user_bindings()
local main_menu = {items = {}, items_by_command = {}} local main_menu = {items = {}, items_by_command = {}}
local by_id = {} local by_id = {}
for line in input_conf_iterator do for _, bind in ipairs(all_user_bindings) do
local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$') local key, command, comment = bind.key, bind.cmd, bind.comment
local title = '' local title = ''
if comment then if comment then
local comments = split(comment, '#') local comments = split(comment, '#')
local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end) local titles = itable_filter(comments, is_uosc_menu_comment)
if titles and #titles > 0 then if titles and #titles > 0 then
title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*') title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
end end
@@ -399,7 +547,6 @@ do
target_menu = by_id[submenu_id] target_menu = by_id[submenu_id]
else else
if command == 'ignore' then break end
-- If command is already in menu, just append the key to it -- If command is already in menu, just append the key to it
if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
local hint = target_menu.items_by_command[command].hint local hint = target_menu.items_by_command[command].hint
@@ -409,7 +556,7 @@ do
if title_part:sub(1, 3) == '---' then if title_part:sub(1, 3) == '---' then
local last_item = target_menu.items[#target_menu.items] local last_item = target_menu.items[#target_menu.items]
if last_item then last_item.separator = true end if last_item then last_item.separator = true end
else elseif command ~= 'ignore' then
local item = { local item = {
title = title_part, title = title_part,
hint = not is_dummy and key or nil, hint = not is_dummy and key or nil,
@@ -430,20 +577,30 @@ do
end end
end end
items = #main_menu.items > 0 and main_menu.items or create_default_menu_items() menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
return items return menu_items
end end
end end
-- Adapted from `stats.lua` -- Adapted from `stats.lua`
function get_keybinds_items() function get_keybinds_items()
local items = {} local items = {}
local active = find_active_keybindings() -- uosc and mpv-menu-plugin binds with no keys
local no_key_menu_binds = itable_filter(
get_all_user_bindings(),
function(b) return b.is_menu_item and b.cmd and b.cmd ~= '' and (b.key == '#' or b.key == '_') end
)
local binds_dump = itable_join(find_active_keybindings(), no_key_menu_binds)
local ids = {}
-- Convert to menu items -- Convert to menu items
for _, bind in pairs(active) do for _, bind in pairs(binds_dump) do
local id = bind.key .. '<>' .. bind.cmd
if not ids[id] then
ids[id] = true
items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd} items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
end end
end
-- Sort -- Sort
table.sort(items, function(a, b) return a.title < b.title end) table.sort(items, function(a, b) return a.title < b.title end)
@@ -467,14 +624,17 @@ function open_stream_quality_menu()
local ytdl_format = mp.get_property_native('ytdl-format') local ytdl_format = mp.get_property_native('ytdl-format')
local items = {} local items = {}
---@type Menu
local menu
for _, height in ipairs(config.stream_quality_options) do for _, height in ipairs(config.stream_quality_options) do
local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']' local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format} items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
end end
Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format) menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event)
mp.set_property('ytdl-format', format) if event.type == 'activate' then
mp.set_property('ytdl-format', event.value)
-- Reload the video to apply new format -- Reload the video to apply new format
-- This is taken from https://github.com/jgreco/mpv-youtube-quality -- This is taken from https://github.com/jgreco/mpv-youtube-quality
@@ -496,6 +656,9 @@ function open_stream_quality_menu()
end end
mp.register_event('file-loaded', seeker) mp.register_event('file-loaded', seeker)
end end
if event.modifiers ~= 'shift' then menu:close() end
end
end) end)
end end
@@ -505,15 +668,14 @@ function open_open_file_menu()
return return
end end
---@type Menu | nil
local menu
local directory local directory
local active_file local active_file
if state.path == nil or is_protocol(state.path) then if state.path == nil or is_protocol(state.path) then
local serialized = serialize_path(get_default_directory()) directory = options.default_directory
if serialized then
directory = serialized.path
active_file = nil active_file = nil
end
else else
local serialized = serialize_path(state.path) local serialized = serialize_path(state.path)
if serialized then if serialized then
@@ -528,7 +690,6 @@ function open_open_file_menu()
end end
-- Update active file in directory navigation menu -- Update active file in directory navigation menu
local menu = nil
local function handle_file_loaded() local function handle_file_loaded()
if menu and menu:is_alive() then if menu and menu:is_alive() then
menu:activate_one_value(normalize_path(mp.get_property_native('path'))) menu:activate_one_value(normalize_path(mp.get_property_native('path')))
@@ -537,12 +698,14 @@ function open_open_file_menu()
menu = open_file_navigation_menu( menu = open_file_navigation_menu(
directory, directory,
function(path, mods) function(event)
if mods.ctrl then if not menu then return end
mp.commandv('loadfile', path, 'append') local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
else if itable_has({'ctrl', 'ctrl+shift'}, event.modifiers) then
mp.commandv('loadfile', path) mp.commandv(command, event.value, 'append')
Menu:close() elseif event.modifiers == '' then
mp.commandv(command, event.value)
menu:close()
end end
end, end,
{ {
@@ -550,10 +713,10 @@ function open_open_file_menu()
allowed_types = config.types.media, allowed_types = config.types.media,
active_path = active_file, active_path = active_file,
keep_open = true, keep_open = true,
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
on_close = function() mp.unregister_event(handle_file_loaded) end, on_close = function() mp.unregister_event(handle_file_loaded) end,
} }
) )
if menu then mp.register_event('file-loaded', handle_file_loaded) end
end end
---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]} ---@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
@@ -581,12 +744,14 @@ function create_track_loader_menu_opener(opts)
end end
end end
if not path then if not path then
path = get_default_directory() path = options.default_directory
end end
local function handle_select(path) load_track(opts.prop, path) end local function handle_activate(event)
if event.modifiers == '' then load_track(opts.prop, event.value) end
end
open_file_navigation_menu(path, handle_select, { open_file_navigation_menu(path, handle_activate, {
type = menu_type, title = title, allowed_types = opts.allowed_types, type = menu_type, title = title, allowed_types = opts.allowed_types,
}) })
end end
@@ -671,7 +836,11 @@ function open_subtitle_downloader()
type = menu_type .. '-result', type = menu_type .. '-result',
search_style = 'disabled', search_style = 'disabled',
items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}}, items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
}, function() end) }, function(event)
if event.type == 'key' and event.key == 'enter' then
menu:close()
end
end)
local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, { local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
'--file-id', tostring(data.id), '--file-id', tostring(data.id),
@@ -822,10 +991,16 @@ function open_subtitle_downloader()
title = t('enter query'), title = t('enter query'),
items = initial_items, items = initial_items,
search_style = 'palette', search_style = 'palette',
on_search = handle_search, on_search = 'callback',
search_debounce = 'submit', search_debounce = 'submit',
search_suggestion = search_suggestion, search_suggestion = search_suggestion,
}, },
handle_select function(event)
if event.type == 'activate' then
handle_select(event.value)
elseif event.type == 'search' then
handle_search(event.query)
end
end
) )
end end

View File

@@ -17,6 +17,11 @@ function serialize_rgba(rgba)
} }
end end
-- Trim any white space from the start and end of the string.
---@param str string
---@return string
function trim(str) return str:match('^%s*(.-)%s*$') end
-- Trim any `char` from the end of the string. -- Trim any `char` from the end of the string.
---@param str string ---@param str string
---@param char string ---@param char string
@@ -217,6 +222,19 @@ function table_assign_props(target, source, props)
return target return target
end end
-- Assign props from `source` to `target` that are not in `props` set.
---@generic T: table<any, any>
---@param target T
---@param source T
---@param props table<string, boolean>
---@return T
function table_assign_exclude(target, source, props)
for key, value in pairs(source) do
if not props[key] then target[key] = value end
end
return target
end
-- `table_assign({}, input)` without loosing types :( -- `table_assign({}, input)` without loosing types :(
---@generic T: table<any, any> ---@generic T: table<any, any>
---@param input T ---@param input T

View File

@@ -4,6 +4,8 @@
---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean} ---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
---@alias Circle {point: Point, r: number, window_drag?: boolean} ---@alias Circle {point: Point, r: number, window_drag?: boolean}
---@alias Hitbox Rect|Circle ---@alias Hitbox Rect|Circle
---@alias Shortcut {id: string; key: string; modifiers: string; alt: boolean; ctrl: boolean; shift: boolean}
---@alias ComplexBindingInfo {event: 'down' | 'repeat' | 'up' | 'press'; is_mouse: boolean; canceled: boolean; key_name?: string; key_text?: string;}
--- In place sorting of filenames --- In place sorting of filenames
---@param filenames string[] ---@param filenames string[]
@@ -398,9 +400,40 @@ function has_any_extension(path, extensions)
return false return false
end end
---@return string ---@param key string
function get_default_directory() ---@param modifiers? string
return mp.command_native({'expand-path', options.default_directory}) ---@return Shortcut
function create_shortcut(key, modifiers)
key = key:lower()
local id_parts, modifiers_set
if modifiers then
id_parts = split(modifiers:lower(), '+')
table.sort(id_parts, function(a, b) return a < b end)
modifiers_set = create_set(id_parts)
modifiers = table.concat(id_parts, '+')
else
id_parts, modifiers, modifiers_set = {}, '', {}
end
id_parts[#id_parts + 1] = key
return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
end
-- Executes mp command defined as a string or an itable, or does nothing if command is any other value.
-- Returns boolean specifying if command was executed or not.
---@param command string | string[] | nil | any
---@return boolean executed `true` if command was executed.
function execute_command(command)
local command_type = type(command)
if command_type == 'string' then
mp.command(command)
return true
elseif command_type == 'table' and #command > 0 then
mp.command_native(command)
return true
end
return false
end end
-- Serializes path into its semantic parts. -- Serializes path into its semantic parts.
@@ -427,19 +460,18 @@ end
-- Reads items in directory and splits it into directories and files tables. -- Reads items in directory and splits it into directories and files tables.
---@param path string ---@param path string
---@param opts? {types?: string[], hidden?: boolean} ---@param opts? {types?: string[], hidden?: boolean}
---@return string[]|nil files ---@return string[] files
---@return string[]|nil directories ---@return string[] directories
---@return string|nil error
function read_directory(path, opts) function read_directory(path, opts)
opts = opts or {} opts = opts or {}
local items, error = utils.readdir(path, 'all') local items, error = utils.readdir(path, 'all')
local files, directories = {}, {}
if not items then if not items then
msg.error('Reading files from "' .. path .. '" failed: ' .. error) return files, directories, 'Reading directory "' .. path .. '" failed. Error: ' .. utils.to_string(error)
return nil, nil
end end
local files, directories = {}, {}
for _, item in ipairs(items) do for _, item in ipairs(items) do
if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
local info = utils.file_info(join_path(path, item)) local info = utils.file_info(join_path(path, item))
@@ -467,8 +499,8 @@ function get_adjacent_files(file_path, opts)
opts = opts or {} opts = opts or {}
local current_meta = serialize_path(file_path) local current_meta = serialize_path(file_path)
if not current_meta then return end if not current_meta then return end
local files = read_directory(current_meta.dirname, {hidden = opts.hidden}) local files, _dirs, error = read_directory(current_meta.dirname, {hidden = opts.hidden})
if not files then return end if error then msg.error(error) return end
sort_strings(files) sort_strings(files)
local current_file_index local current_file_index
local paths = {} local paths = {}
@@ -782,18 +814,20 @@ end
---@return {[string]: table}|table ---@return {[string]: table}|table
function find_active_keybindings(key) function find_active_keybindings(key)
local bindings = mp.get_property_native('input-bindings', {}) local bindings = mp.get_property_native('input-bindings', {})
local active = {} -- map: key-name -> bind-info local active_map = {} -- map: key-name -> bind-info
local active_table = {}
for _, bind in pairs(bindings) do for _, bind in pairs(bindings) do
if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and ( if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
not active[bind.key] not active_map[bind.key]
or (active[bind.key].is_weak and not bind.is_weak) or (active_map[bind.key].is_weak and not bind.is_weak)
or (bind.is_weak == active[bind.key].is_weak and bind.priority > active[bind.key].priority) or (bind.is_weak == active_map[bind.key].is_weak and bind.priority > active_map[bind.key].priority)
) )
then then
active[bind.key] = bind active_table[#active_table + 1] = bind
active_map[bind.key] = bind
end end
end end
return not key and active or active[key] return key and active_map[key] or active_table
end end
---@param type 'sub'|'audio'|'video' ---@param type 'sub'|'audio'|'video'

View File

@@ -51,7 +51,7 @@ defaults = {
top_bar = 'no-border', top_bar = 'no-border',
top_bar_size = 40, top_bar_size = 40,
top_bar_persistency = '', top_bar_persistency = '',
top_bar_controls = true, top_bar_controls = 'right',
top_bar_title = 'yes', top_bar_title = 'yes',
top_bar_alt_title = '', top_bar_alt_title = '',
top_bar_alt_title_place = 'below', top_bar_alt_title_place = 'below',
@@ -92,6 +92,7 @@ defaults = {
'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv', 'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv',
image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp', image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp',
subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt', subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
playlist_types = 'm3u,m3u8,pls,url,cue',
default_directory = '~/', default_directory = '~/',
show_hidden_files = false, show_hidden_files = false,
use_trash = false, use_trash = false,
@@ -126,6 +127,9 @@ if options.total_time and options.destination_time == 'playtime-remaining' then
elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then
options.destination_time = 'playtime-remaining' options.destination_time = 'playtime-remaining'
end end
if not itable_index_of({'left', 'right'}, options.top_bar_controls) then
options.top_bar_controls = options.top_bar_controls == 'yes' and 'right' or nil
end
-- Ensure required environment configuration -- Ensure required environment configuration
if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
@@ -184,7 +188,11 @@ config = {
audio = comma_split(options.audio_types), audio = comma_split(options.audio_types),
image = comma_split(options.image_types), image = comma_split(options.image_types),
subtitle = comma_split(options.subtitle_types), subtitle = comma_split(options.subtitle_types),
media = comma_split(options.video_types .. ',' .. options.audio_types .. ',' .. options.image_types), playlist = comma_split(options.playlist_types),
media = comma_split(options.video_types
.. ',' .. options.audio_types
.. ',' .. options.image_types
.. ',' .. options.playlist_types),
autoload = (function() autoload = (function()
---@type string[] ---@type string[]
local option_values = {} local option_values = {}
@@ -337,11 +345,13 @@ state = {
alt_title = nil, alt_title = nil,
time = nil, -- current media playback time time = nil, -- current media playback time
speed = 1, speed = 1,
---@type number|nil
duration = nil, -- current media duration duration = nil, -- current media duration
time_human = nil, -- current playback time in human format time_human = nil, -- current playback time in human format
destination_time_human = nil, -- depends on options.destination_time destination_time_human = nil, -- depends on options.destination_time
pause = mp.get_property_native('pause'), pause = mp.get_property_native('pause'),
chapters = {}, chapters = {},
---@type {index: number; title: string}|nil
current_chapter = nil, current_chapter = nil,
chapter_ranges = {}, chapter_ranges = {},
border = mp.get_property_native('border'), border = mp.get_property_native('border'),
@@ -373,6 +383,7 @@ state = {
cache = nil, cache = nil,
cache_buffering = 100, cache_buffering = 100,
cache_underrun = false, cache_underrun = false,
cache_duration = nil,
core_idle = false, core_idle = false,
eof_reached = false, eof_reached = false,
render_delay = config.render_delay, render_delay = config.render_delay,
@@ -386,6 +397,7 @@ state = {
scale = 1, scale = 1,
radius = 0, radius = 0,
} }
buttons = require('lib/buttons')
thumbnail = {width = 0, height = 0, disabled = false} thumbnail = {width = 0, height = 0, disabled = false}
external = {} -- Properties set by external scripts external = {} -- Properties set by external scripts
key_binding_overwrites = {} -- Table of key_binding:mpv_command key_binding_overwrites = {} -- Table of key_binding:mpv_command
@@ -542,12 +554,16 @@ function load_file_index_in_current_directory(index)
local serialized = serialize_path(state.path) local serialized = serialize_path(state.path)
if serialized and serialized.dirname then if serialized and serialized.dirname then
local files = read_directory(serialized.dirname, { local files, _dirs, error = read_directory(serialized.dirname, {
types = config.types.autoload, types = config.types.autoload,
hidden = options.show_hidden_files, hidden = options.show_hidden_files,
}) })
if not files then return end if error then
msg.error(error)
return
end
sort_strings(files) sort_strings(files)
if index < 0 then index = #files + index + 1 end if index < 0 then index = #files + index + 1 end
@@ -570,11 +586,18 @@ function observe_display_fps(name, fps)
end end
function select_current_chapter() function select_current_chapter()
local current_chapter_index = state.current_chapter and state.current_chapter.index
local current_chapter local current_chapter
if state.time and state.chapters then if state.time and state.chapters then
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1) _, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
end end
local new_chapter_index = current_chapter and current_chapter.index
if current_chapter_index ~= new_chapter_index then
set_state('current_chapter', current_chapter) set_state('current_chapter', current_chapter)
if itable_has(config.top_bar_flash_on, 'chapter') then
Elements:flash({'top_bar'})
end
end
end end
--[[ STATE HOOKS ]] --[[ STATE HOOKS ]]
@@ -766,6 +789,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
if cache_state then if cache_state then
cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached'] cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
set_state('cache_underrun', cache_state['underrun']) set_state('cache_underrun', cache_state['underrun'])
set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
else else
cached_ranges = {} cached_ranges = {}
end end
@@ -773,6 +797,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
(state.cache == 'auto' and state.is_stream))) then (state.cache == 'auto' and state.is_stream))) then
if state.uncached_ranges then set_state('uncached_ranges', nil) end if state.uncached_ranges then set_state('uncached_ranges', nil) end
set_state('cache_duration', nil)
return return
end end
@@ -886,11 +911,12 @@ bind_command('playlist', create_self_updating_menu_opener({
end end
return items return items
end, end,
on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end, on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
on_move_item = function(from, to) on_move = function(event)
mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1)) local from, to = event.from_index, event.to_index
mp.commandv('playlist-move', tostring(from - 1), tostring(to - (to > from and 0 or 1)))
end, end,
on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end, on_remove = function(event) mp.commandv('playlist-remove', tostring(event.index - 1)) end,
})) }))
bind_command('chapters', create_self_updating_menu_opener({ bind_command('chapters', create_self_updating_menu_opener({
title = t('Chapters'), title = t('Chapters'),
@@ -910,7 +936,7 @@ bind_command('chapters', create_self_updating_menu_opener({
end end
return items return items
end, end,
on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end, on_activate = function(event) mp.commandv('set', 'chapter', tostring(event.value - 1)) end,
})) }))
bind_command('editions', create_self_updating_menu_opener({ bind_command('editions', create_self_updating_menu_opener({
title = t('Editions'), title = t('Editions'),
@@ -930,7 +956,7 @@ bind_command('editions', create_self_updating_menu_opener({
end end
return items return items
end, end,
on_select = function(id) mp.commandv('set', 'edition', id) end, on_activate = function(event) mp.commandv('set', 'edition', event.value) end,
})) }))
bind_command('show-in-directory', function() bind_command('show-in-directory', function()
-- Ignore URLs -- Ignore URLs
@@ -1011,7 +1037,7 @@ bind_command('audio-device', create_self_updating_menu_opener({
end end
return items return items
end, end,
on_select = function(name) mp.commandv('set', 'audio-device', name) end, on_activate = function(event) mp.commandv('set', 'audio-device', event.value) end,
})) }))
bind_command('open-config-directory', function() bind_command('open-config-directory', function()
local config_path = mp.command_native({'expand-path', '~~/mpv.conf'}) local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
@@ -1060,6 +1086,17 @@ mp.register_script_message('update-menu', function(json)
if menu then menu:update(data) end if menu then menu:update(data) end
end end
end) end)
mp.register_script_message('select-menu-item', function(type, item_index, menu_id)
local menu = Menu:is_open(type)
local index = tonumber(item_index)
if menu and index and not menu.mouse_nav then
index = round(index)
if index > 0 and index <= #menu.current.items then
menu:select_index(index, menu_id)
menu:scroll_to_index(index, menu_id, true)
end
end
end)
mp.register_script_message('close-menu', function(type) mp.register_script_message('close-menu', function(type)
if Menu:is_open(type) then Menu:close() end if Menu:is_open(type) then Menu:close() end
end) end)