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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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