├── LICENSE.md ├── README.md ├── osc.patch ├── preview.jpg ├── quality-menu-osc.lua ├── quality-menu-preview-osc.jpg ├── quality-menu.conf └── quality-menu.lua /LICENSE.md: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quality-menu 2 | A userscript for MPV that allows you to change the streamed video and audio quality (ytdl-format) on the fly. 3 | 4 | Simply open the video or audio menu, select your prefered format and confirm your choice. The keybindings for opening the menus are configured in input.conf, and everthing else is configured in quality-menu.conf. 5 | 6 | Note: Version 4.2.0 and later require mpv 0.39.0. Anyone on an older version of mpv can use version 4.1.2 instead. 7 | 8 | ![screenshot](preview.jpg) 9 | 10 | ## Features 11 | 12 | - Currently playing format is marked and selected when opening the menu 13 | - Remembers selected format for every url in the current session (e.g. going back to previous playlist item automatically selects the prefered format) 14 | - Controllable entirely by mouse or keyboard (opening by mouse requires either the OSC extension, [uosc](https://github.com/tomasklaen/uosc) or an additional entry in [`input.conf`](https://mpv.io/manual/stable/#input-conf)) 15 | - All format related information from yt-dlp/youtube-dl can be shown 16 | - Columns that are identical for all formats are automatically hidden 17 | - Formats can be sorted based on resolution, fps, bitrate, etc. 18 | - Simple reload functionality 19 | - Columns and their order are configurable 20 | - **(optional)** Graphical menu via [uosc](https://github.com/tomasklaen/uosc) integration 21 | Note: Requires uosc 5.0.0 or newer. 22 | 23 | ## OSC extension 24 | **(optional)** An extended version of the OSC is available that includes a button to display the quality menu. 25 | 26 | ![screenshot](quality-menu-preview-osc.jpg) 27 | 28 | **PLEASE NOTE:** This conflicts with other scripts that modify the OSC. Merging this OSC modification others is certainly possible. Depending on how the osc is modified, the [osc.patch](osc.patch) might apply cleanly, but you have to make sure the filename in the patch lines up with the filename of your files. 29 | 30 | ## Installation 31 | 1. Save the `quality-menu.lua` into your [scripts directory](https://mpv.io/manual/stable/#script-location) 32 | 2. Set key bindings in [`input.conf`](https://mpv.io/manual/stable/#input-conf) 33 | ``` 34 | F script-binding quality_menu/video_formats_toggle 35 | Alt+f script-binding quality_menu/audio_formats_toggle 36 | ``` 37 | **(optional)** `Ctrl+r script-binding quality_menu/reload` 38 | 39 | 3. **(optional)** Save the `quality-menu.conf` into your `script-opts` directory (next to the [scripts directory](https://mpv.io/manual/stable/#script-location), create if it doesn't exist) 40 | 4. **(optional)** UI integration (pick one) 41 | - For OSC: Save the `quality-menu-osc.lua` into your [scripts directory](https://mpv.io/manual/stable/#script-location) and put `osc=no` in your [mpv.conf](https://mpv.io/manual/stable/#location-and-syntax) 42 | - For [uosc](https://github.com/tomasklaen/uosc) (each is optional) 43 | 1. Add the video and audio menu to the uosc menu by appending `#! ...` to your key bindings in [`input.conf`](https://mpv.io/manual/stable/#input-conf) 44 | ``` 45 | F script-binding quality_menu/video_formats_toggle #! Stream Quality > Video 46 | Alt+f script-binding quality_menu/audio_formats_toggle #! Stream Quality > Audio 47 | ``` 48 | 2. quality-menu already overwrites the builtin uosc command `stream-quality`, so it already works well out of the box. 49 | For even deeper UI integration you can add buttons to the `contols=` option in your [`uosc.conf`](https://github.com/tomasklaen/uosc/blob/main/script-opts/uosc.conf) 50 | 1. `command:theaters:script-binding quality_menu/video_formats_toggle#@vformats>1?Video` 51 | 2. `command:graphic_eq:script-binding quality_menu/audio_formats_toggle#@aformats>1?Audio` 52 | 53 | ## Plans For Future Enhancement 54 | - [x] Visual indication of what the current quality level is 55 | - [x] Option to populate the quality list automatically with the exact formats available for a given video 56 | - [x] Optional OSC extension 57 | - [x] [uosc](https://github.com/tomasklaen/uosc) integration 58 | - [ ] *\[your suggestion here\]* 59 | 60 | ## Credit 61 | - [reload.lua](https://github.com/4e6/mpv-reload/), for the function to reload a video while preserving the playlist. 62 | - [mpv-playlistmanager](https://github.com/jonniek/mpv-playlistmanager), for the menu formatting config. 63 | - ytdl_hook.lua, much of the code to fetch the format list with youtube-dl came from there. 64 | - somebody on /mpv/ for the idea 65 | -------------------------------------------------------------------------------- /osc.patch: -------------------------------------------------------------------------------- 1 | diff --git a/osc.lua b/osc.lua 2 | index 0a9965b..afd6047 100644 3 | --- a/osc.lua 4 | +++ b/osc.lua 5 | @@ -1615,6 +1615,13 @@ function bar_layout(direction) 6 | lo.geometry = geo 7 | lo.style = osc_styles.smallButtonsBar 8 | 9 | + -- START quality-menu 10 | + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 11 | + lo = add_layout("quality-menu") 12 | + lo.geometry = geo 13 | + lo.style = osc_styles.smallButtonsBar 14 | + -- END quality-menu 15 | + 16 | -- Volume 17 | geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 18 | lo = add_layout("volume") 19 | @@ -2124,6 +2131,17 @@ function osc_init() 20 | string.format("%3.0fs", sec)) 21 | end 22 | 23 | + -- START quality-menu 24 | + ne = new_element("quality-menu", "button") 25 | + ne.content = function() 26 | + return ("≚") 27 | + end 28 | + ne.eventresponder["mbtn_left_up"] = 29 | + function () mp.commandv("script-message", "video_formats_toggle") end 30 | + ne.eventresponder["mbtn_right_up"] = 31 | + function () mp.commandv("script-message", "audio_formats_toggle") end 32 | + -- END quality-menu 33 | + 34 | -- volume 35 | ne = new_element("volume", "button") 36 | 37 | @@ -2206,15 +2224,21 @@ function update_margins() 38 | reset_margins() 39 | end 40 | 41 | + if utils.shared_script_property_set then 42 | utils.shared_script_property_set("osc-margins", 43 | string.format("%f,%f,%f,%f", margins.l, margins.r, margins.t, margins.b)) 44 | + end 45 | mp.set_property_native("user-data/osc/margins", margins) 46 | end 47 | 48 | function shutdown() 49 | reset_margins() 50 | + if utils.shared_script_property_set then 51 | utils.shared_script_property_set("osc-margins", nil) 52 | + end 53 | + if mp.del_property then 54 | mp.del_property("user-data/osc") 55 | + end 56 | end 57 | 58 | -- 59 | @@ -2870,7 +2894,9 @@ function visibility_mode(mode, no_osd) 60 | end 61 | 62 | user_opts.visibility = mode 63 | + if utils.shared_script_property_set then 64 | utils.shared_script_property_set("osc-visibility", mode) 65 | + end 66 | mp.set_property_native("user-data/osc/visibility", mode) 67 | 68 | if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then 69 | @@ -2903,7 +2929,9 @@ function idlescreen_visibility(mode, no_osd) 70 | user_opts.idlescreen = false 71 | end 72 | 73 | + if utils.shared_script_property_set then 74 | utils.shared_script_property_set("osc-idlescreen", mode) 75 | + end 76 | mp.set_property_native("user-data/osc/idlescreen", user_opts.idlescreen) 77 | 78 | if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then 79 | -------------------------------------------------------------------------------- /preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christoph-heinrich/mpv-quality-menu/6e4dc5ee8d41b422239ae504c52647a1478675b7/preview.jpg -------------------------------------------------------------------------------- /quality-menu-osc.lua: -------------------------------------------------------------------------------- 1 | local assdraw = require 'mp.assdraw' 2 | local msg = require 'mp.msg' 3 | local opt = require 'mp.options' 4 | local utils = require 'mp.utils' 5 | 6 | -- 7 | -- Parameters 8 | -- 9 | -- default user option values 10 | -- do not touch, change them in osc.conf 11 | local user_opts = { 12 | showwindowed = true, -- show OSC when windowed? 13 | showfullscreen = true, -- show OSC when fullscreen? 14 | idlescreen = true, -- show mpv logo on idle 15 | scalewindowed = 1, -- scaling of the controller when windowed 16 | scalefullscreen = 1, -- scaling of the controller when fullscreen 17 | scaleforcedwindow = 2, -- scaling when rendered on a forced window 18 | vidscale = true, -- scale the controller with the video? 19 | valign = 0.8, -- vertical alignment, -1 (top) to 1 (bottom) 20 | halign = 0, -- horizontal alignment, -1 (left) to 1 (right) 21 | barmargin = 0, -- vertical margin of top/bottombar 22 | boxalpha = 80, -- alpha of the background box, 23 | -- 0 (opaque) to 255 (fully transparent) 24 | hidetimeout = 500, -- duration in ms until the OSC hides if no 25 | -- mouse movement. enforced non-negative for the 26 | -- user, but internally negative is "always-on". 27 | fadeduration = 200, -- duration of fade out in ms, 0 = no fade 28 | deadzonesize = 0.5, -- size of deadzone 29 | minmousemove = 0, -- minimum amount of pixels the mouse has to 30 | -- move between ticks to make the OSC show up 31 | iamaprogrammer = false, -- use native mpv values and disable OSC 32 | -- internal track list management (and some 33 | -- functions that depend on it) 34 | layout = "bottombar", 35 | seekbarstyle = "bar", -- bar, diamond or knob 36 | seekbarhandlesize = 0.6, -- size ratio of the diamond and knob handle 37 | seekrangestyle = "inverted",-- bar, line, slider, inverted or none 38 | seekrangeseparate = true, -- whether the seekranges overlay on the bar-style seekbar 39 | seekrangealpha = 200, -- transparency of seekranges 40 | seekbarkeyframes = true, -- use keyframes when dragging the seekbar 41 | title = "${media-title}", -- string compatible with property-expansion 42 | -- to be shown as OSC title 43 | tooltipborder = 1, -- border of tooltip in bottom/topbar 44 | timetotal = false, -- display total time instead of remaining time? 45 | remaining_playtime = true, -- display the remaining time in playtime or video-time mode 46 | -- playtime takes speed into account, whereas video-time doesn't 47 | timems = false, -- display timecodes with milliseconds? 48 | tcspace = 100, -- timecode spacing (compensate font size estimation) 49 | visibility = "auto", -- only used at init to set visibility_mode(...) 50 | boxmaxchars = 80, -- title crop threshold for box layout 51 | boxvideo = false, -- apply osc_param.video_margins to video 52 | windowcontrols = "auto", -- whether to show window controls 53 | windowcontrols_alignment = "right", -- which side to show window controls on 54 | greenandgrumpy = false, -- disable santa hat 55 | livemarkers = true, -- update seekbar chapter markers on duration change 56 | chapters_osd = true, -- whether to show chapters OSD on next/prev 57 | playlist_osd = true, -- whether to show playlist OSD on next/prev 58 | chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable 59 | unicodeminus = false, -- whether to use the Unicode minus sign character 60 | } 61 | 62 | -- read options from config and command-line 63 | opt.read_options(user_opts, "osc", function(list) update_options(list) end) 64 | 65 | local osc_param = { -- calculated by osc_init() 66 | playresy = 0, -- canvas size Y 67 | playresx = 0, -- canvas size X 68 | display_aspect = 1, 69 | unscaled_y = 0, 70 | areas = {}, 71 | video_margins = { 72 | l = 0, r = 0, t = 0, b = 0, -- left/right/top/bottom 73 | }, 74 | } 75 | 76 | local osc_styles = { 77 | bigButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs50\\fnmpv-osd-symbols}", 78 | smallButtonsL = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs19\\fnmpv-osd-symbols}", 79 | smallButtonsLlabel = "{\\fscx105\\fscy105\\fn" .. mp.get_property("options/osd-font") .. "}", 80 | smallButtonsR = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs30\\fnmpv-osd-symbols}", 81 | topButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\fnmpv-osd-symbols}", 82 | 83 | elementDown = "{\\1c&H999999}", 84 | timecodes = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs20}", 85 | vidtitle = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\q2}", 86 | box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", 87 | 88 | topButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\fnmpv-osd-symbols}", 89 | smallButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs28\\fnmpv-osd-symbols}", 90 | timecodesBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs27}", 91 | timePosBar = "{\\blur0\\bord".. user_opts.tooltipborder .."\\1c&HFFFFFF\\3c&H000000\\fs30}", 92 | vidtitleBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\q2}", 93 | 94 | wcButtons = "{\\1c&HFFFFFF\\fs24\\fnmpv-osd-symbols}", 95 | wcTitle = "{\\1c&HFFFFFF\\fs24\\q2}", 96 | wcBar = "{\\1c&H000000}", 97 | } 98 | 99 | -- internal states, do not touch 100 | local state = { 101 | showtime, -- time of last invocation (last mouse move) 102 | osc_visible = false, 103 | anistart, -- time when the animation started 104 | anitype, -- current type of animation 105 | animation, -- current animation alpha 106 | mouse_down_counter = 0, -- used for softrepeat 107 | active_element = nil, -- nil = none, 0 = background, 1+ = see elements[] 108 | active_event_source = nil, -- the "button" that issued the current event 109 | rightTC_trem = not user_opts.timetotal, -- if the right timecode should display total or remaining time 110 | tc_ms = user_opts.timems, -- Should the timecodes display their time with milliseconds 111 | mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs 112 | initREQ = false, -- is a re-init request pending? 113 | marginsREQ = false, -- is a margins update pending? 114 | last_mouseX, last_mouseY, -- last mouse position, to detect significant mouse movement 115 | mouse_in_window = false, 116 | message_text, 117 | message_hide_timer, 118 | fullscreen = false, 119 | tick_timer = nil, 120 | tick_last_time = 0, -- when the last tick() was run 121 | hide_timer = nil, 122 | cache_state = nil, 123 | idle = false, 124 | enabled = true, 125 | input_enabled = true, 126 | showhide_enabled = false, 127 | windowcontrols_buttons = false, 128 | dmx_cache = 0, 129 | using_video_margins = false, 130 | border = true, 131 | maximized = false, 132 | osd = mp.create_osd_overlay("ass-events"), 133 | chapter_list = {}, -- sorted by time 134 | } 135 | 136 | local window_control_box_width = 80 137 | local tick_delay = 0.03 138 | 139 | local is_december = os.date("*t").month == 12 140 | 141 | -- 142 | -- Helperfunctions 143 | -- 144 | 145 | function kill_animation() 146 | state.anistart = nil 147 | state.animation = nil 148 | state.anitype = nil 149 | end 150 | 151 | function set_osd(res_x, res_y, text) 152 | if state.osd.res_x == res_x and 153 | state.osd.res_y == res_y and 154 | state.osd.data == text then 155 | return 156 | end 157 | state.osd.res_x = res_x 158 | state.osd.res_y = res_y 159 | state.osd.data = text 160 | state.osd.z = 1000 161 | state.osd:update() 162 | end 163 | 164 | local margins_opts = { 165 | {"l", "video-margin-ratio-left"}, 166 | {"r", "video-margin-ratio-right"}, 167 | {"t", "video-margin-ratio-top"}, 168 | {"b", "video-margin-ratio-bottom"}, 169 | } 170 | 171 | -- scale factor for translating between real and virtual ASS coordinates 172 | function get_virt_scale_factor() 173 | local w, h = mp.get_osd_size() 174 | if w <= 0 or h <= 0 then 175 | return 0, 0 176 | end 177 | return osc_param.playresx / w, osc_param.playresy / h 178 | end 179 | 180 | -- return mouse position in virtual ASS coordinates (playresx/y) 181 | function get_virt_mouse_pos() 182 | if state.mouse_in_window then 183 | local sx, sy = get_virt_scale_factor() 184 | local x, y = mp.get_mouse_pos() 185 | return x * sx, y * sy 186 | else 187 | return -1, -1 188 | end 189 | end 190 | 191 | function set_virt_mouse_area(x0, y0, x1, y1, name) 192 | local sx, sy = get_virt_scale_factor() 193 | mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name) 194 | end 195 | 196 | function scale_value(x0, x1, y0, y1, val) 197 | local m = (y1 - y0) / (x1 - x0) 198 | local b = y0 - (m * x0) 199 | return (m * val) + b 200 | end 201 | 202 | -- returns hitbox spanning coordinates (top left, bottom right corner) 203 | -- according to alignment 204 | function get_hitbox_coords(x, y, an, w, h) 205 | 206 | local alignments = { 207 | [1] = function () return x, y-h, x+w, y end, 208 | [2] = function () return x-(w/2), y-h, x+(w/2), y end, 209 | [3] = function () return x-w, y-h, x, y end, 210 | 211 | [4] = function () return x, y-(h/2), x+w, y+(h/2) end, 212 | [5] = function () return x-(w/2), y-(h/2), x+(w/2), y+(h/2) end, 213 | [6] = function () return x-w, y-(h/2), x, y+(h/2) end, 214 | 215 | [7] = function () return x, y, x+w, y+h end, 216 | [8] = function () return x-(w/2), y, x+(w/2), y+h end, 217 | [9] = function () return x-w, y, x, y+h end, 218 | } 219 | 220 | return alignments[an]() 221 | end 222 | 223 | function get_hitbox_coords_geo(geometry) 224 | return get_hitbox_coords(geometry.x, geometry.y, geometry.an, 225 | geometry.w, geometry.h) 226 | end 227 | 228 | function get_element_hitbox(element) 229 | return element.hitbox.x1, element.hitbox.y1, 230 | element.hitbox.x2, element.hitbox.y2 231 | end 232 | 233 | function mouse_hit(element) 234 | return mouse_hit_coords(get_element_hitbox(element)) 235 | end 236 | 237 | function mouse_hit_coords(bX1, bY1, bX2, bY2) 238 | local mX, mY = get_virt_mouse_pos() 239 | return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2) 240 | end 241 | 242 | function limit_range(min, max, val) 243 | if val > max then 244 | val = max 245 | elseif val < min then 246 | val = min 247 | end 248 | return val 249 | end 250 | 251 | -- translate value into element coordinates 252 | function get_slider_ele_pos_for(element, val) 253 | 254 | local ele_pos = scale_value( 255 | element.slider.min.value, element.slider.max.value, 256 | element.slider.min.ele_pos, element.slider.max.ele_pos, 257 | val) 258 | 259 | return limit_range( 260 | element.slider.min.ele_pos, element.slider.max.ele_pos, 261 | ele_pos) 262 | end 263 | 264 | -- translates global (mouse) coordinates to value 265 | function get_slider_value_at(element, glob_pos) 266 | 267 | local val = scale_value( 268 | element.slider.min.glob_pos, element.slider.max.glob_pos, 269 | element.slider.min.value, element.slider.max.value, 270 | glob_pos) 271 | 272 | return limit_range( 273 | element.slider.min.value, element.slider.max.value, 274 | val) 275 | end 276 | 277 | -- get value at current mouse position 278 | function get_slider_value(element) 279 | return get_slider_value_at(element, get_virt_mouse_pos()) 280 | end 281 | 282 | function countone(val) 283 | if not (user_opts.iamaprogrammer) then 284 | val = val + 1 285 | end 286 | return val 287 | end 288 | 289 | -- align: -1 .. +1 290 | -- frame: size of the containing area 291 | -- obj: size of the object that should be positioned inside the area 292 | -- margin: min. distance from object to frame (as long as -1 <= align <= +1) 293 | function get_align(align, frame, obj, margin) 294 | return (frame / 2) + (((frame / 2) - margin - (obj / 2)) * align) 295 | end 296 | 297 | -- multiplies two alpha values, formular can probably be improved 298 | function mult_alpha(alphaA, alphaB) 299 | return 255 - (((1-(alphaA/255)) * (1-(alphaB/255))) * 255) 300 | end 301 | 302 | function add_area(name, x1, y1, x2, y2) 303 | -- create area if needed 304 | if (osc_param.areas[name] == nil) then 305 | osc_param.areas[name] = {} 306 | end 307 | table.insert(osc_param.areas[name], {x1=x1, y1=y1, x2=x2, y2=y2}) 308 | end 309 | 310 | function ass_append_alpha(ass, alpha, modifier) 311 | local ar = {} 312 | 313 | for ai, av in pairs(alpha) do 314 | av = mult_alpha(av, modifier) 315 | if state.animation then 316 | av = mult_alpha(av, state.animation) 317 | end 318 | ar[ai] = av 319 | end 320 | 321 | ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}", 322 | ar[1], ar[2], ar[3], ar[4])) 323 | end 324 | 325 | function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2) 326 | if hexagon then 327 | ass:hexagon_cw(x0, y0, x1, y1, r1, r2) 328 | else 329 | ass:round_rect_cw(x0, y0, x1, y1, r1, r2) 330 | end 331 | end 332 | 333 | function ass_draw_rr_h_ccw(ass, x0, y0, x1, y1, r1, hexagon, r2) 334 | if hexagon then 335 | ass:hexagon_ccw(x0, y0, x1, y1, r1, r2) 336 | else 337 | ass:round_rect_ccw(x0, y0, x1, y1, r1, r2) 338 | end 339 | end 340 | 341 | 342 | -- 343 | -- Tracklist Management 344 | -- 345 | 346 | local nicetypes = {video = "Video", audio = "Audio", sub = "Subtitle"} 347 | 348 | -- updates the OSC internal playlists, should be run each time the track-layout changes 349 | function update_tracklist() 350 | local tracktable = mp.get_property_native("track-list", {}) 351 | 352 | -- by osc_id 353 | tracks_osc = {} 354 | tracks_osc.video, tracks_osc.audio, tracks_osc.sub = {}, {}, {} 355 | -- by mpv_id 356 | tracks_mpv = {} 357 | tracks_mpv.video, tracks_mpv.audio, tracks_mpv.sub = {}, {}, {} 358 | for n = 1, #tracktable do 359 | if not (tracktable[n].type == "unknown") then 360 | local type = tracktable[n].type 361 | local mpv_id = tonumber(tracktable[n].id) 362 | 363 | -- by osc_id 364 | table.insert(tracks_osc[type], tracktable[n]) 365 | 366 | -- by mpv_id 367 | tracks_mpv[type][mpv_id] = tracktable[n] 368 | tracks_mpv[type][mpv_id].osc_id = #tracks_osc[type] 369 | end 370 | end 371 | end 372 | 373 | -- return a nice list of tracks of the given type (video, audio, sub) 374 | function get_tracklist(type) 375 | local msg = "Available " .. nicetypes[type] .. " Tracks: " 376 | if not tracks_osc or #tracks_osc[type] == 0 then 377 | msg = msg .. "none" 378 | else 379 | for n = 1, #tracks_osc[type] do 380 | local track = tracks_osc[type][n] 381 | local lang, title, selected = "unknown", "", "○" 382 | if not(track.lang == nil) then lang = track.lang end 383 | if not(track.title == nil) then title = track.title end 384 | if (track.id == tonumber(mp.get_property(type))) then 385 | selected = "●" 386 | end 387 | msg = msg.."\n"..selected.." "..n..": ["..lang.."] "..title 388 | end 389 | end 390 | return msg 391 | end 392 | 393 | -- relatively change the track of given by tracks 394 | --(+1 -> next, -1 -> previous) 395 | function set_track(type, next) 396 | local current_track_mpv, current_track_osc 397 | if (mp.get_property(type) == "no") then 398 | current_track_osc = 0 399 | else 400 | current_track_mpv = tonumber(mp.get_property(type)) 401 | current_track_osc = tracks_mpv[type][current_track_mpv].osc_id 402 | end 403 | local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1) 404 | local new_track_mpv 405 | if new_track_osc == 0 then 406 | new_track_mpv = "no" 407 | else 408 | new_track_mpv = tracks_osc[type][new_track_osc].id 409 | end 410 | 411 | mp.commandv("set", type, new_track_mpv) 412 | 413 | if (new_track_osc == 0) then 414 | show_message(nicetypes[type] .. " Track: none") 415 | else 416 | show_message(nicetypes[type] .. " Track: " 417 | .. new_track_osc .. "/" .. #tracks_osc[type] 418 | .. " [".. (tracks_osc[type][new_track_osc].lang or "unknown") .."] " 419 | .. (tracks_osc[type][new_track_osc].title or "")) 420 | end 421 | end 422 | 423 | -- get the currently selected track of , OSC-style counted 424 | function get_track(type) 425 | local track = mp.get_property(type) 426 | if track ~= "no" and track ~= nil then 427 | local tr = tracks_mpv[type][tonumber(track)] 428 | if tr then 429 | return tr.osc_id 430 | end 431 | end 432 | return 0 433 | end 434 | 435 | -- WindowControl helpers 436 | function window_controls_enabled() 437 | val = user_opts.windowcontrols 438 | if val == "auto" then 439 | return not state.border 440 | else 441 | return val ~= "no" 442 | end 443 | end 444 | 445 | function window_controls_alignment() 446 | return user_opts.windowcontrols_alignment 447 | end 448 | 449 | -- 450 | -- Element Management 451 | -- 452 | 453 | local elements = {} 454 | 455 | function prepare_elements() 456 | 457 | -- remove elements without layout or invisible 458 | local elements2 = {} 459 | for n, element in pairs(elements) do 460 | if not (element.layout == nil) and (element.visible) then 461 | table.insert(elements2, element) 462 | end 463 | end 464 | elements = elements2 465 | 466 | function elem_compare (a, b) 467 | return a.layout.layer < b.layout.layer 468 | end 469 | 470 | table.sort(elements, elem_compare) 471 | 472 | 473 | for _,element in pairs(elements) do 474 | 475 | local elem_geo = element.layout.geometry 476 | 477 | -- Calculate the hitbox 478 | local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo) 479 | element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2} 480 | 481 | local style_ass = assdraw.ass_new() 482 | 483 | -- prepare static elements 484 | style_ass:append("{}") -- hack to troll new_event into inserting a \n 485 | style_ass:new_event() 486 | style_ass:pos(elem_geo.x, elem_geo.y) 487 | style_ass:an(elem_geo.an) 488 | style_ass:append(element.layout.style) 489 | 490 | element.style_ass = style_ass 491 | 492 | local static_ass = assdraw.ass_new() 493 | 494 | 495 | if (element.type == "box") then 496 | --draw box 497 | static_ass:draw_start() 498 | ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, 499 | element.layout.box.radius, element.layout.box.hexagon) 500 | static_ass:draw_stop() 501 | 502 | elseif (element.type == "slider") then 503 | --draw static slider parts 504 | 505 | local r1 = 0 506 | local r2 = 0 507 | local slider_lo = element.layout.slider 508 | -- offset between element outline and drag-area 509 | local foV = slider_lo.border + slider_lo.gap 510 | 511 | -- calculate positions of min and max points 512 | if (slider_lo.stype ~= "bar") then 513 | r1 = elem_geo.h / 2 514 | element.slider.min.ele_pos = elem_geo.h / 2 515 | element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2) 516 | if (slider_lo.stype == "diamond") then 517 | r2 = (elem_geo.h - 2 * slider_lo.border) / 2 518 | elseif (slider_lo.stype == "knob") then 519 | r2 = r1 520 | end 521 | else 522 | element.slider.min.ele_pos = 523 | slider_lo.border + slider_lo.gap 524 | element.slider.max.ele_pos = 525 | elem_geo.w - (slider_lo.border + slider_lo.gap) 526 | end 527 | 528 | element.slider.min.glob_pos = 529 | element.hitbox.x1 + element.slider.min.ele_pos 530 | element.slider.max.glob_pos = 531 | element.hitbox.x1 + element.slider.max.ele_pos 532 | 533 | -- -- -- 534 | 535 | static_ass:draw_start() 536 | 537 | -- the box 538 | ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, r1, slider_lo.stype == "diamond") 539 | 540 | -- the "hole" 541 | ass_draw_rr_h_ccw(static_ass, slider_lo.border, slider_lo.border, 542 | elem_geo.w - slider_lo.border, elem_geo.h - slider_lo.border, 543 | r2, slider_lo.stype == "diamond") 544 | 545 | -- marker nibbles 546 | if not (element.slider.markerF == nil) and (slider_lo.gap > 0) then 547 | local markers = element.slider.markerF() 548 | for _,marker in pairs(markers) do 549 | if (marker > element.slider.min.value) and 550 | (marker < element.slider.max.value) then 551 | 552 | local s = get_slider_ele_pos_for(element, marker) 553 | 554 | if (slider_lo.gap > 1) then -- draw triangles 555 | 556 | local a = slider_lo.gap / 0.5 --0.866 557 | 558 | --top 559 | if (slider_lo.nibbles_top) then 560 | static_ass:move_to(s - (a/2), slider_lo.border) 561 | static_ass:line_to(s + (a/2), slider_lo.border) 562 | static_ass:line_to(s, foV) 563 | end 564 | 565 | --bottom 566 | if (slider_lo.nibbles_bottom) then 567 | static_ass:move_to(s - (a/2), 568 | elem_geo.h - slider_lo.border) 569 | static_ass:line_to(s, 570 | elem_geo.h - foV) 571 | static_ass:line_to(s + (a/2), 572 | elem_geo.h - slider_lo.border) 573 | end 574 | 575 | else -- draw 2x1px nibbles 576 | 577 | --top 578 | if (slider_lo.nibbles_top) then 579 | static_ass:rect_cw(s - 1, slider_lo.border, 580 | s + 1, slider_lo.border + slider_lo.gap); 581 | end 582 | 583 | --bottom 584 | if (slider_lo.nibbles_bottom) then 585 | static_ass:rect_cw(s - 1, 586 | elem_geo.h -slider_lo.border -slider_lo.gap, 587 | s + 1, elem_geo.h - slider_lo.border); 588 | end 589 | end 590 | end 591 | end 592 | end 593 | end 594 | 595 | element.static_ass = static_ass 596 | 597 | 598 | -- if the element is supposed to be disabled, 599 | -- style it accordingly and kill the eventresponders 600 | if not (element.enabled) then 601 | element.layout.alpha[1] = 136 602 | element.eventresponder = nil 603 | end 604 | end 605 | end 606 | 607 | 608 | -- 609 | -- Element Rendering 610 | -- 611 | 612 | -- returns nil or a chapter element from the native property chapter-list 613 | function get_chapter(possec) 614 | local cl = state.chapter_list -- sorted, get latest before possec, if any 615 | 616 | for n=#cl,1,-1 do 617 | if possec >= cl[n].time then 618 | return cl[n] 619 | end 620 | end 621 | end 622 | 623 | function render_elements(master_ass) 624 | 625 | -- when the slider is dragged or hovered and we have a target chapter name 626 | -- then we use it instead of the normal title. we calculate it before the 627 | -- render iterations because the title may be rendered before the slider. 628 | state.forced_title = nil 629 | local se, ae = state.slider_element, elements[state.active_element] 630 | if user_opts.chapter_fmt ~= "no" and se and (ae == se or (not ae and mouse_hit(se))) then 631 | local dur = mp.get_property_number("duration", 0) 632 | if dur > 0 then 633 | local possec = get_slider_value(se) * dur / 100 -- of mouse pos 634 | local ch = get_chapter(possec) 635 | if ch and ch.title and ch.title ~= "" then 636 | state.forced_title = string.format(user_opts.chapter_fmt, ch.title) 637 | end 638 | end 639 | end 640 | 641 | for n=1, #elements do 642 | local element = elements[n] 643 | 644 | local style_ass = assdraw.ass_new() 645 | style_ass:merge(element.style_ass) 646 | ass_append_alpha(style_ass, element.layout.alpha, 0) 647 | 648 | if element.eventresponder and (state.active_element == n) then 649 | 650 | -- run render event functions 651 | if not (element.eventresponder.render == nil) then 652 | element.eventresponder.render(element) 653 | end 654 | 655 | if mouse_hit(element) then 656 | -- mouse down styling 657 | if (element.styledown) then 658 | style_ass:append(osc_styles.elementDown) 659 | end 660 | 661 | if (element.softrepeat) and (state.mouse_down_counter >= 15 662 | and state.mouse_down_counter % 5 == 0) then 663 | 664 | element.eventresponder[state.active_event_source.."_down"](element) 665 | end 666 | state.mouse_down_counter = state.mouse_down_counter + 1 667 | end 668 | 669 | end 670 | 671 | local elem_ass = assdraw.ass_new() 672 | 673 | elem_ass:merge(style_ass) 674 | 675 | if not (element.type == "button") then 676 | elem_ass:merge(element.static_ass) 677 | end 678 | 679 | if (element.type == "slider") then 680 | 681 | local slider_lo = element.layout.slider 682 | local elem_geo = element.layout.geometry 683 | local s_min = element.slider.min.value 684 | local s_max = element.slider.max.value 685 | 686 | -- draw pos marker 687 | local foH, xp 688 | local pos = element.slider.posF() 689 | local foV = slider_lo.border + slider_lo.gap 690 | local innerH = elem_geo.h - (2 * foV) 691 | local seekRanges = element.slider.seekRangesF() 692 | local seekRangeLineHeight = innerH / 5 693 | 694 | if slider_lo.stype ~= "bar" then 695 | foH = elem_geo.h / 2 696 | else 697 | foH = slider_lo.border + slider_lo.gap 698 | end 699 | 700 | if pos then 701 | xp = get_slider_ele_pos_for(element, pos) 702 | 703 | if slider_lo.stype ~= "bar" then 704 | local r = (user_opts.seekbarhandlesize * innerH) / 2 705 | ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, 706 | xp + r, foH + r, 707 | r, slider_lo.stype == "diamond") 708 | else 709 | local h = 0 710 | if seekRanges and user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then 711 | h = seekRangeLineHeight 712 | end 713 | elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV - h) 714 | 715 | if seekRanges and not user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then 716 | -- Punch holes for the seekRanges to be drawn later 717 | for _,range in pairs(seekRanges) do 718 | if range["start"] < pos then 719 | local pstart = get_slider_ele_pos_for(element, range["start"]) 720 | local pend = xp 721 | 722 | if pos > range["end"] then 723 | pend = get_slider_ele_pos_for(element, range["end"]) 724 | end 725 | elem_ass:rect_ccw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) 726 | end 727 | end 728 | end 729 | end 730 | 731 | if slider_lo.rtype == "slider" then 732 | ass_draw_rr_h_cw(elem_ass, foH - innerH / 6, foH - innerH / 6, 733 | xp, foH + innerH / 6, 734 | innerH / 6, slider_lo.stype == "diamond", 0) 735 | ass_draw_rr_h_cw(elem_ass, xp, foH - innerH / 15, 736 | elem_geo.w - foH + innerH / 15, foH + innerH / 15, 737 | 0, slider_lo.stype == "diamond", innerH / 15) 738 | for _,range in pairs(seekRanges or {}) do 739 | local pstart = get_slider_ele_pos_for(element, range["start"]) 740 | local pend = get_slider_ele_pos_for(element, range["end"]) 741 | ass_draw_rr_h_ccw(elem_ass, pstart, foH - innerH / 21, 742 | pend, foH + innerH / 21, 743 | innerH / 21, slider_lo.stype == "diamond") 744 | end 745 | end 746 | end 747 | 748 | if seekRanges then 749 | if slider_lo.rtype ~= "inverted" then 750 | elem_ass:draw_stop() 751 | elem_ass:merge(element.style_ass) 752 | ass_append_alpha(elem_ass, element.layout.alpha, user_opts.seekrangealpha) 753 | elem_ass:merge(element.static_ass) 754 | end 755 | 756 | for _,range in pairs(seekRanges) do 757 | local pstart = get_slider_ele_pos_for(element, range["start"]) 758 | local pend = get_slider_ele_pos_for(element, range["end"]) 759 | 760 | if slider_lo.rtype == "slider" then 761 | ass_draw_rr_h_cw(elem_ass, pstart, foH - innerH / 21, 762 | pend, foH + innerH / 21, 763 | innerH / 21, slider_lo.stype == "diamond") 764 | elseif slider_lo.rtype == "line" then 765 | if slider_lo.stype == "bar" then 766 | elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) 767 | else 768 | ass_draw_rr_h_cw(elem_ass, pstart - innerH / 8, foH - innerH / 8, 769 | pend + innerH / 8, foH + innerH / 8, 770 | innerH / 8, slider_lo.stype == "diamond") 771 | end 772 | elseif slider_lo.rtype == "bar" then 773 | if slider_lo.stype ~= "bar" then 774 | ass_draw_rr_h_cw(elem_ass, pstart - innerH / 2, foV, 775 | pend + innerH / 2, foV + innerH, 776 | innerH / 2, slider_lo.stype == "diamond") 777 | elseif range["end"] >= (pos or 0) then 778 | elem_ass:rect_cw(pstart, foV, pend, elem_geo.h - foV) 779 | else 780 | elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) 781 | end 782 | elseif slider_lo.rtype == "inverted" then 783 | if slider_lo.stype ~= "bar" then 784 | ass_draw_rr_h_ccw(elem_ass, pstart, (elem_geo.h / 2) - 1, pend, 785 | (elem_geo.h / 2) + 1, 786 | 1, slider_lo.stype == "diamond") 787 | else 788 | elem_ass:rect_ccw(pstart, (elem_geo.h / 2) - 1, pend, (elem_geo.h / 2) + 1) 789 | end 790 | end 791 | end 792 | end 793 | 794 | elem_ass:draw_stop() 795 | 796 | -- add tooltip 797 | if not (element.slider.tooltipF == nil) then 798 | 799 | if mouse_hit(element) then 800 | local sliderpos = get_slider_value(element) 801 | local tooltiplabel = element.slider.tooltipF(sliderpos) 802 | 803 | local an = slider_lo.tooltip_an 804 | 805 | local ty 806 | 807 | if (an == 2) then 808 | ty = element.hitbox.y1 - slider_lo.border 809 | else 810 | ty = element.hitbox.y1 + elem_geo.h/2 811 | end 812 | 813 | local tx = get_virt_mouse_pos() 814 | if (slider_lo.adjust_tooltip) then 815 | if (an == 2) then 816 | if (sliderpos < (s_min + 3)) then 817 | an = an - 1 818 | elseif (sliderpos > (s_max - 3)) then 819 | an = an + 1 820 | end 821 | elseif (sliderpos > (s_max-s_min)/2) then 822 | an = an + 1 823 | tx = tx - 5 824 | else 825 | an = an - 1 826 | tx = tx + 10 827 | end 828 | end 829 | 830 | -- tooltip label 831 | elem_ass:new_event() 832 | elem_ass:pos(tx, ty) 833 | elem_ass:an(an) 834 | elem_ass:append(slider_lo.tooltip_style) 835 | ass_append_alpha(elem_ass, slider_lo.alpha, 0) 836 | elem_ass:append(tooltiplabel) 837 | 838 | end 839 | end 840 | 841 | elseif (element.type == "button") then 842 | 843 | local buttontext 844 | if type(element.content) == "function" then 845 | buttontext = element.content() -- function objects 846 | elseif not (element.content == nil) then 847 | buttontext = element.content -- text objects 848 | end 849 | 850 | local maxchars = element.layout.button.maxchars 851 | if not (maxchars == nil) and (#buttontext > maxchars) then 852 | local max_ratio = 1.25 -- up to 25% more chars while shrinking 853 | local limit = math.max(0, math.floor(maxchars * max_ratio) - 3) 854 | if (#buttontext > limit) then 855 | while (#buttontext > limit) do 856 | buttontext = buttontext:gsub(".[\128-\191]*$", "") 857 | end 858 | buttontext = buttontext .. "..." 859 | end 860 | local _, nchars2 = buttontext:gsub(".[\128-\191]*", "") 861 | local stretch = (maxchars/#buttontext)*100 862 | buttontext = string.format("{\\fscx%f}", 863 | (maxchars/#buttontext)*100) .. buttontext 864 | end 865 | 866 | elem_ass:append(buttontext) 867 | end 868 | 869 | master_ass:merge(elem_ass) 870 | end 871 | end 872 | 873 | -- 874 | -- Message display 875 | -- 876 | 877 | -- pos is 1 based 878 | function limited_list(prop, pos) 879 | local proplist = mp.get_property_native(prop, {}) 880 | local count = #proplist 881 | if count == 0 then 882 | return count, proplist 883 | end 884 | 885 | local fs = tonumber(mp.get_property('options/osd-font-size')) 886 | local max = math.ceil(osc_param.unscaled_y*0.75 / fs) 887 | if max % 2 == 0 then 888 | max = max - 1 889 | end 890 | local delta = math.ceil(max / 2) - 1 891 | local begi = math.max(math.min(pos - delta, count - max + 1), 1) 892 | local endi = math.min(begi + max - 1, count) 893 | 894 | local reslist = {} 895 | for i=begi, endi do 896 | local item = proplist[i] 897 | item.current = (i == pos) and true or nil 898 | table.insert(reslist, item) 899 | end 900 | return count, reslist 901 | end 902 | 903 | function get_playlist() 904 | local pos = mp.get_property_number('playlist-pos', 0) + 1 905 | local count, limlist = limited_list('playlist', pos) 906 | if count == 0 then 907 | return 'Empty playlist.' 908 | end 909 | 910 | local message = string.format('Playlist [%d/%d]:\n', pos, count) 911 | for i, v in ipairs(limlist) do 912 | local title = v.title 913 | local _, filename = utils.split_path(v.filename) 914 | if title == nil then 915 | title = filename 916 | end 917 | message = string.format('%s %s %s\n', message, 918 | (v.current and '●' or '○'), title) 919 | end 920 | return message 921 | end 922 | 923 | function get_chapterlist() 924 | local pos = mp.get_property_number('chapter', 0) + 1 925 | local count, limlist = limited_list('chapter-list', pos) 926 | if count == 0 then 927 | return 'No chapters.' 928 | end 929 | 930 | local message = string.format('Chapters [%d/%d]:\n', pos, count) 931 | for i, v in ipairs(limlist) do 932 | local time = mp.format_time(v.time) 933 | local title = v.title 934 | if title == nil then 935 | title = string.format('Chapter %02d', i) 936 | end 937 | message = string.format('%s[%s] %s %s\n', message, time, 938 | (v.current and '●' or '○'), title) 939 | end 940 | return message 941 | end 942 | 943 | function show_message(text, duration) 944 | 945 | --print("text: "..text.." duration: " .. duration) 946 | if duration == nil then 947 | duration = tonumber(mp.get_property("options/osd-duration")) / 1000 948 | elseif not type(duration) == "number" then 949 | print("duration: " .. duration) 950 | end 951 | 952 | -- cut the text short, otherwise the following functions 953 | -- may slow down massively on huge input 954 | text = string.sub(text, 0, 4000) 955 | 956 | -- replace actual linebreaks with ASS linebreaks 957 | text = string.gsub(text, "\n", "\\N") 958 | 959 | state.message_text = text 960 | 961 | if not state.message_hide_timer then 962 | state.message_hide_timer = mp.add_timeout(0, request_tick) 963 | end 964 | state.message_hide_timer:kill() 965 | state.message_hide_timer.timeout = duration 966 | state.message_hide_timer:resume() 967 | request_tick() 968 | end 969 | 970 | function render_message(ass) 971 | if state.message_hide_timer and state.message_hide_timer:is_enabled() and 972 | state.message_text 973 | then 974 | local _, lines = string.gsub(state.message_text, "\\N", "") 975 | 976 | local fontsize = tonumber(mp.get_property("options/osd-font-size")) 977 | local outline = tonumber(mp.get_property("options/osd-border-size")) 978 | local maxlines = math.ceil(osc_param.unscaled_y*0.75 / fontsize) 979 | local counterscale = osc_param.playresy / osc_param.unscaled_y 980 | 981 | fontsize = fontsize * counterscale / math.max(0.65 + math.min(lines/maxlines, 1), 1) 982 | outline = outline * counterscale / math.max(0.75 + math.min(lines/maxlines, 1)/2, 1) 983 | 984 | local style = "{\\bord" .. outline .. "\\fs" .. fontsize .. "}" 985 | 986 | 987 | ass:new_event() 988 | ass:append(style .. state.message_text) 989 | else 990 | state.message_text = nil 991 | end 992 | end 993 | 994 | -- 995 | -- Initialisation and Layout 996 | -- 997 | 998 | function new_element(name, type) 999 | elements[name] = {} 1000 | elements[name].type = type 1001 | 1002 | -- add default stuff 1003 | elements[name].eventresponder = {} 1004 | elements[name].visible = true 1005 | elements[name].enabled = true 1006 | elements[name].softrepeat = false 1007 | elements[name].styledown = (type == "button") 1008 | elements[name].state = {} 1009 | 1010 | if (type == "slider") then 1011 | elements[name].slider = {min = {value = 0}, max = {value = 100}} 1012 | end 1013 | 1014 | 1015 | return elements[name] 1016 | end 1017 | 1018 | function add_layout(name) 1019 | if not (elements[name] == nil) then 1020 | -- new layout 1021 | elements[name].layout = {} 1022 | 1023 | -- set layout defaults 1024 | elements[name].layout.layer = 50 1025 | elements[name].layout.alpha = {[1] = 0, [2] = 255, [3] = 255, [4] = 255} 1026 | 1027 | if (elements[name].type == "button") then 1028 | elements[name].layout.button = { 1029 | maxchars = nil, 1030 | } 1031 | elseif (elements[name].type == "slider") then 1032 | -- slider defaults 1033 | elements[name].layout.slider = { 1034 | border = 1, 1035 | gap = 1, 1036 | nibbles_top = true, 1037 | nibbles_bottom = true, 1038 | stype = "slider", 1039 | adjust_tooltip = true, 1040 | tooltip_style = "", 1041 | tooltip_an = 2, 1042 | alpha = {[1] = 0, [2] = 255, [3] = 88, [4] = 255}, 1043 | } 1044 | elseif (elements[name].type == "box") then 1045 | elements[name].layout.box = {radius = 0, hexagon = false} 1046 | end 1047 | 1048 | return elements[name].layout 1049 | else 1050 | msg.error("Can't add_layout to element \""..name.."\", doesn't exist.") 1051 | end 1052 | end 1053 | 1054 | -- Window Controls 1055 | function window_controls(topbar) 1056 | local wc_geo = { 1057 | x = 0, 1058 | y = 30 + user_opts.barmargin, 1059 | an = 1, 1060 | w = osc_param.playresx, 1061 | h = 30, 1062 | } 1063 | 1064 | local alignment = window_controls_alignment() 1065 | local controlbox_w = window_control_box_width 1066 | local titlebox_w = wc_geo.w - controlbox_w 1067 | 1068 | -- Default alignment is "right" 1069 | local controlbox_left = wc_geo.w - controlbox_w 1070 | local titlebox_left = wc_geo.x 1071 | local titlebox_right = wc_geo.w - controlbox_w 1072 | 1073 | if alignment == "left" then 1074 | controlbox_left = wc_geo.x 1075 | titlebox_left = wc_geo.x + controlbox_w 1076 | titlebox_right = wc_geo.w 1077 | end 1078 | 1079 | add_area("window-controls", 1080 | get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, 1081 | controlbox_w, wc_geo.h)) 1082 | 1083 | local lo 1084 | 1085 | -- Background Bar 1086 | new_element("wcbar", "box") 1087 | lo = add_layout("wcbar") 1088 | lo.geometry = wc_geo 1089 | lo.layer = 10 1090 | lo.style = osc_styles.wcBar 1091 | lo.alpha[1] = user_opts.boxalpha 1092 | 1093 | local button_y = wc_geo.y - (wc_geo.h / 2) 1094 | local first_geo = 1095 | {x = controlbox_left + 5, y = button_y, an = 4, w = 25, h = 25} 1096 | local second_geo = 1097 | {x = controlbox_left + 30, y = button_y, an = 4, w = 25, h = 25} 1098 | local third_geo = 1099 | {x = controlbox_left + 55, y = button_y, an = 4, w = 25, h = 25} 1100 | 1101 | -- Window control buttons use symbols in the custom mpv osd font 1102 | -- because the official unicode codepoints are sufficiently 1103 | -- exotic that a system might lack an installed font with them, 1104 | -- and libass will complain that they are not present in the 1105 | -- default font, even if another font with them is available. 1106 | 1107 | -- Close: 🗙 1108 | ne = new_element("close", "button") 1109 | ne.content = "\238\132\149" 1110 | ne.eventresponder["mbtn_left_up"] = 1111 | function () mp.commandv("quit") end 1112 | lo = add_layout("close") 1113 | lo.geometry = alignment == "left" and first_geo or third_geo 1114 | lo.style = osc_styles.wcButtons 1115 | 1116 | -- Minimize: 🗕 1117 | ne = new_element("minimize", "button") 1118 | ne.content = "\238\132\146" 1119 | ne.eventresponder["mbtn_left_up"] = 1120 | function () mp.commandv("cycle", "window-minimized") end 1121 | lo = add_layout("minimize") 1122 | lo.geometry = alignment == "left" and second_geo or first_geo 1123 | lo.style = osc_styles.wcButtons 1124 | 1125 | -- Maximize: 🗖 /🗗 1126 | ne = new_element("maximize", "button") 1127 | if state.maximized or state.fullscreen then 1128 | ne.content = "\238\132\148" 1129 | else 1130 | ne.content = "\238\132\147" 1131 | end 1132 | ne.eventresponder["mbtn_left_up"] = 1133 | function () 1134 | if state.fullscreen then 1135 | mp.commandv("cycle", "fullscreen") 1136 | else 1137 | mp.commandv("cycle", "window-maximized") 1138 | end 1139 | end 1140 | lo = add_layout("maximize") 1141 | lo.geometry = alignment == "left" and third_geo or second_geo 1142 | lo.style = osc_styles.wcButtons 1143 | 1144 | -- deadzone below window controls 1145 | local sh_area_y0, sh_area_y1 1146 | sh_area_y0 = user_opts.barmargin 1147 | sh_area_y1 = wc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), 1148 | osc_param.playresy - wc_geo.y, 0, 0) 1149 | add_area("showhide_wc", wc_geo.x, sh_area_y0, wc_geo.w, sh_area_y1) 1150 | 1151 | if topbar then 1152 | -- The title is already there as part of the top bar 1153 | return 1154 | else 1155 | -- Apply boxvideo margins to the control bar 1156 | osc_param.video_margins.t = wc_geo.h / osc_param.playresy 1157 | end 1158 | 1159 | -- Window Title 1160 | ne = new_element("wctitle", "button") 1161 | ne.content = function () 1162 | local title = mp.command_native({"expand-text", user_opts.title}) 1163 | -- escape ASS, and strip newlines and trailing slashes 1164 | title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") 1165 | return not (title == "") and title or "mpv" 1166 | end 1167 | local left_pad = 5 1168 | local right_pad = 10 1169 | lo = add_layout("wctitle") 1170 | lo.geometry = 1171 | { x = titlebox_left + left_pad, y = wc_geo.y - 3, an = 1, 1172 | w = titlebox_w, h = wc_geo.h } 1173 | lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", 1174 | osc_styles.wcTitle, 1175 | titlebox_left + left_pad, wc_geo.y - wc_geo.h, 1176 | titlebox_right - right_pad , wc_geo.y + wc_geo.h) 1177 | 1178 | add_area("window-controls-title", 1179 | titlebox_left, 0, titlebox_right, wc_geo.h) 1180 | end 1181 | 1182 | -- 1183 | -- Layouts 1184 | -- 1185 | 1186 | local layouts = {} 1187 | 1188 | -- Classic box layout 1189 | layouts["box"] = function () 1190 | 1191 | local osc_geo = { 1192 | w = 550, -- width 1193 | h = 138, -- height 1194 | r = 10, -- corner-radius 1195 | p = 15, -- padding 1196 | } 1197 | 1198 | -- make sure the OSC actually fits into the video 1199 | if (osc_param.playresx < (osc_geo.w + (2 * osc_geo.p))) then 1200 | osc_param.playresy = (osc_geo.w+(2*osc_geo.p))/osc_param.display_aspect 1201 | osc_param.playresx = osc_param.playresy * osc_param.display_aspect 1202 | end 1203 | 1204 | -- position of the controller according to video aspect and valignment 1205 | local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, 1206 | osc_geo.w, 0)) 1207 | local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, 1208 | osc_geo.h, 0)) 1209 | 1210 | -- position offset for contents aligned at the borders of the box 1211 | local pos_offsetX = (osc_geo.w - (2*osc_geo.p)) / 2 1212 | local pos_offsetY = (osc_geo.h - (2*osc_geo.p)) / 2 1213 | 1214 | osc_param.areas = {} -- delete areas 1215 | 1216 | -- area for active mouse input 1217 | add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) 1218 | 1219 | -- area for show/hide 1220 | local sh_area_y0, sh_area_y1 1221 | if user_opts.valign > 0 then 1222 | -- deadzone above OSC 1223 | sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), 1224 | posY - (osc_geo.h / 2), 0, 0) 1225 | sh_area_y1 = osc_param.playresy 1226 | else 1227 | -- deadzone below OSC 1228 | sh_area_y0 = 0 1229 | sh_area_y1 = (posY + (osc_geo.h / 2)) + 1230 | get_align(1 - (2*user_opts.deadzonesize), 1231 | osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) 1232 | end 1233 | add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) 1234 | 1235 | -- fetch values 1236 | local osc_w, osc_h, osc_r, osc_p = 1237 | osc_geo.w, osc_geo.h, osc_geo.r, osc_geo.p 1238 | 1239 | local lo 1240 | 1241 | -- 1242 | -- Background box 1243 | -- 1244 | 1245 | new_element("bgbox", "box") 1246 | lo = add_layout("bgbox") 1247 | 1248 | lo.geometry = {x = posX, y = posY, an = 5, w = osc_w, h = osc_h} 1249 | lo.layer = 10 1250 | lo.style = osc_styles.box 1251 | lo.alpha[1] = user_opts.boxalpha 1252 | lo.alpha[3] = user_opts.boxalpha 1253 | lo.box.radius = osc_r 1254 | 1255 | -- 1256 | -- Title row 1257 | -- 1258 | 1259 | local titlerowY = posY - pos_offsetY - 10 1260 | 1261 | lo = add_layout("title") 1262 | lo.geometry = {x = posX, y = titlerowY, an = 8, w = 496, h = 12} 1263 | lo.style = osc_styles.vidtitle 1264 | lo.button.maxchars = user_opts.boxmaxchars 1265 | 1266 | lo = add_layout("pl_prev") 1267 | lo.geometry = 1268 | {x = (posX - pos_offsetX), y = titlerowY, an = 7, w = 12, h = 12} 1269 | lo.style = osc_styles.topButtons 1270 | 1271 | lo = add_layout("pl_next") 1272 | lo.geometry = 1273 | {x = (posX + pos_offsetX), y = titlerowY, an = 9, w = 12, h = 12} 1274 | lo.style = osc_styles.topButtons 1275 | 1276 | -- 1277 | -- Big buttons 1278 | -- 1279 | 1280 | local bigbtnrowY = posY - pos_offsetY + 35 1281 | local bigbtndist = 60 1282 | 1283 | lo = add_layout("playpause") 1284 | lo.geometry = 1285 | {x = posX, y = bigbtnrowY, an = 5, w = 40, h = 40} 1286 | lo.style = osc_styles.bigButtons 1287 | 1288 | lo = add_layout("skipback") 1289 | lo.geometry = 1290 | {x = posX - bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} 1291 | lo.style = osc_styles.bigButtons 1292 | 1293 | lo = add_layout("skipfrwd") 1294 | lo.geometry = 1295 | {x = posX + bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} 1296 | lo.style = osc_styles.bigButtons 1297 | 1298 | lo = add_layout("ch_prev") 1299 | lo.geometry = 1300 | {x = posX - (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} 1301 | lo.style = osc_styles.bigButtons 1302 | 1303 | lo = add_layout("ch_next") 1304 | lo.geometry = 1305 | {x = posX + (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} 1306 | lo.style = osc_styles.bigButtons 1307 | 1308 | lo = add_layout("cy_audio") 1309 | lo.geometry = 1310 | {x = posX - pos_offsetX, y = bigbtnrowY, an = 1, w = 70, h = 18} 1311 | lo.style = osc_styles.smallButtonsL 1312 | 1313 | lo = add_layout("cy_sub") 1314 | lo.geometry = 1315 | {x = posX - pos_offsetX, y = bigbtnrowY, an = 7, w = 70, h = 18} 1316 | lo.style = osc_styles.smallButtonsL 1317 | 1318 | lo = add_layout("tog_fs") 1319 | lo.geometry = 1320 | {x = posX+pos_offsetX - 25, y = bigbtnrowY, an = 4, w = 25, h = 25} 1321 | lo.style = osc_styles.smallButtonsR 1322 | 1323 | lo = add_layout("volume") 1324 | lo.geometry = 1325 | {x = posX+pos_offsetX - (25 * 2) - osc_geo.p, 1326 | y = bigbtnrowY, an = 4, w = 25, h = 25} 1327 | lo.style = osc_styles.smallButtonsR 1328 | 1329 | -- 1330 | -- Seekbar 1331 | -- 1332 | 1333 | lo = add_layout("seekbar") 1334 | lo.geometry = 1335 | {x = posX, y = posY+pos_offsetY-22, an = 2, w = pos_offsetX*2, h = 15} 1336 | lo.style = osc_styles.timecodes 1337 | lo.slider.tooltip_style = osc_styles.vidtitle 1338 | lo.slider.stype = user_opts["seekbarstyle"] 1339 | lo.slider.rtype = user_opts["seekrangestyle"] 1340 | 1341 | -- 1342 | -- Timecodes + Cache 1343 | -- 1344 | 1345 | local bottomrowY = posY + pos_offsetY - 5 1346 | 1347 | lo = add_layout("tc_left") 1348 | lo.geometry = 1349 | {x = posX - pos_offsetX, y = bottomrowY, an = 4, w = 110, h = 18} 1350 | lo.style = osc_styles.timecodes 1351 | 1352 | lo = add_layout("tc_right") 1353 | lo.geometry = 1354 | {x = posX + pos_offsetX, y = bottomrowY, an = 6, w = 110, h = 18} 1355 | lo.style = osc_styles.timecodes 1356 | 1357 | lo = add_layout("cache") 1358 | lo.geometry = 1359 | {x = posX, y = bottomrowY, an = 5, w = 110, h = 18} 1360 | lo.style = osc_styles.timecodes 1361 | 1362 | end 1363 | 1364 | -- slim box layout 1365 | layouts["slimbox"] = function () 1366 | 1367 | local osc_geo = { 1368 | w = 660, -- width 1369 | h = 70, -- height 1370 | r = 10, -- corner-radius 1371 | } 1372 | 1373 | -- make sure the OSC actually fits into the video 1374 | if (osc_param.playresx < (osc_geo.w)) then 1375 | osc_param.playresy = (osc_geo.w)/osc_param.display_aspect 1376 | osc_param.playresx = osc_param.playresy * osc_param.display_aspect 1377 | end 1378 | 1379 | -- position of the controller according to video aspect and valignment 1380 | local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, 1381 | osc_geo.w, 0)) 1382 | local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, 1383 | osc_geo.h, 0)) 1384 | 1385 | osc_param.areas = {} -- delete areas 1386 | 1387 | -- area for active mouse input 1388 | add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) 1389 | 1390 | -- area for show/hide 1391 | local sh_area_y0, sh_area_y1 1392 | if user_opts.valign > 0 then 1393 | -- deadzone above OSC 1394 | sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), 1395 | posY - (osc_geo.h / 2), 0, 0) 1396 | sh_area_y1 = osc_param.playresy 1397 | else 1398 | -- deadzone below OSC 1399 | sh_area_y0 = 0 1400 | sh_area_y1 = (posY + (osc_geo.h / 2)) + 1401 | get_align(1 - (2*user_opts.deadzonesize), 1402 | osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) 1403 | end 1404 | add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) 1405 | 1406 | local lo 1407 | 1408 | local tc_w, ele_h, inner_w = 100, 20, osc_geo.w - 100 1409 | 1410 | -- styles 1411 | local styles = { 1412 | box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", 1413 | timecodes = "{\\1c&HFFFFFF\\3c&H000000\\fs20\\bord2\\blur1}", 1414 | tooltip = "{\\1c&HFFFFFF\\3c&H000000\\fs12\\bord1\\blur0.5}", 1415 | } 1416 | 1417 | 1418 | new_element("bgbox", "box") 1419 | lo = add_layout("bgbox") 1420 | 1421 | lo.geometry = {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} 1422 | lo.layer = 10 1423 | lo.style = osc_styles.box 1424 | lo.alpha[1] = user_opts.boxalpha 1425 | lo.alpha[3] = 0 1426 | if not (user_opts["seekbarstyle"] == "bar") then 1427 | lo.box.radius = osc_geo.r 1428 | lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" 1429 | end 1430 | 1431 | 1432 | lo = add_layout("seekbar") 1433 | lo.geometry = 1434 | {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} 1435 | lo.style = osc_styles.timecodes 1436 | lo.slider.border = 0 1437 | lo.slider.gap = 1.5 1438 | lo.slider.tooltip_style = styles.tooltip 1439 | lo.slider.stype = user_opts["seekbarstyle"] 1440 | lo.slider.rtype = user_opts["seekrangestyle"] 1441 | lo.slider.adjust_tooltip = false 1442 | 1443 | -- 1444 | -- Timecodes 1445 | -- 1446 | 1447 | lo = add_layout("tc_left") 1448 | lo.geometry = 1449 | {x = posX - (inner_w/2) + osc_geo.r, y = posY + 1, 1450 | an = 7, w = tc_w, h = ele_h} 1451 | lo.style = styles.timecodes 1452 | lo.alpha[3] = user_opts.boxalpha 1453 | 1454 | lo = add_layout("tc_right") 1455 | lo.geometry = 1456 | {x = posX + (inner_w/2) - osc_geo.r, y = posY + 1, 1457 | an = 9, w = tc_w, h = ele_h} 1458 | lo.style = styles.timecodes 1459 | lo.alpha[3] = user_opts.boxalpha 1460 | 1461 | -- Cache 1462 | 1463 | lo = add_layout("cache") 1464 | lo.geometry = 1465 | {x = posX, y = posY + 1, 1466 | an = 8, w = tc_w, h = ele_h} 1467 | lo.style = styles.timecodes 1468 | lo.alpha[3] = user_opts.boxalpha 1469 | 1470 | 1471 | end 1472 | 1473 | function bar_layout(direction) 1474 | local osc_geo = { 1475 | x = -2, 1476 | y, 1477 | an = (direction < 0) and 7 or 1, 1478 | w, 1479 | h = 56, 1480 | } 1481 | 1482 | local padX = 9 1483 | local padY = 3 1484 | local buttonW = 27 1485 | local tcW = (state.tc_ms) and 170 or 110 1486 | if user_opts.tcspace >= 50 and user_opts.tcspace <= 200 then 1487 | -- adjust our hardcoded font size estimation 1488 | tcW = tcW * user_opts.tcspace / 100 1489 | end 1490 | 1491 | local tsW = 90 1492 | local minW = (buttonW + padX)*5 + (tcW + padX)*4 + (tsW + padX)*2 1493 | 1494 | -- Special topbar handling when window controls are present 1495 | local padwc_l 1496 | local padwc_r 1497 | if direction < 0 or not window_controls_enabled() then 1498 | padwc_l = 0 1499 | padwc_r = 0 1500 | elseif window_controls_alignment() == "left" then 1501 | padwc_l = window_control_box_width 1502 | padwc_r = 0 1503 | else 1504 | padwc_l = 0 1505 | padwc_r = window_control_box_width 1506 | end 1507 | 1508 | if ((osc_param.display_aspect > 0) and (osc_param.playresx < minW)) then 1509 | osc_param.playresy = minW / osc_param.display_aspect 1510 | osc_param.playresx = osc_param.playresy * osc_param.display_aspect 1511 | end 1512 | 1513 | osc_geo.y = direction * (54 + user_opts.barmargin) 1514 | osc_geo.w = osc_param.playresx + 4 1515 | if direction < 0 then 1516 | osc_geo.y = osc_geo.y + osc_param.playresy 1517 | end 1518 | 1519 | local line1 = osc_geo.y - direction * (9 + padY) 1520 | local line2 = osc_geo.y - direction * (36 + padY) 1521 | 1522 | osc_param.areas = {} 1523 | 1524 | add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an, 1525 | osc_geo.w, osc_geo.h)) 1526 | 1527 | local sh_area_y0, sh_area_y1 1528 | if direction > 0 then 1529 | -- deadzone below OSC 1530 | sh_area_y0 = user_opts.barmargin 1531 | sh_area_y1 = osc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), 1532 | osc_param.playresy - osc_geo.y, 0, 0) 1533 | else 1534 | -- deadzone above OSC 1535 | sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), osc_geo.y, 0, 0) 1536 | sh_area_y1 = osc_param.playresy - user_opts.barmargin 1537 | end 1538 | add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) 1539 | 1540 | local lo, geo 1541 | 1542 | -- Background bar 1543 | new_element("bgbox", "box") 1544 | lo = add_layout("bgbox") 1545 | 1546 | lo.geometry = osc_geo 1547 | lo.layer = 10 1548 | lo.style = osc_styles.box 1549 | lo.alpha[1] = user_opts.boxalpha 1550 | 1551 | 1552 | -- Playlist prev/next 1553 | geo = { x = osc_geo.x + padX, y = line1, 1554 | an = 4, w = 18, h = 18 - padY } 1555 | lo = add_layout("pl_prev") 1556 | lo.geometry = geo 1557 | lo.style = osc_styles.topButtonsBar 1558 | 1559 | geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1560 | lo = add_layout("pl_next") 1561 | lo.geometry = geo 1562 | lo.style = osc_styles.topButtonsBar 1563 | 1564 | local t_l = geo.x + geo.w + padX 1565 | 1566 | -- Cache 1567 | geo = { x = osc_geo.x + osc_geo.w - padX, y = geo.y, 1568 | an = 6, w = 150, h = geo.h } 1569 | lo = add_layout("cache") 1570 | lo.geometry = geo 1571 | lo.style = osc_styles.vidtitleBar 1572 | 1573 | local t_r = geo.x - geo.w - padX*2 1574 | 1575 | -- Title 1576 | geo = { x = t_l, y = geo.y, an = 4, 1577 | w = t_r - t_l, h = geo.h } 1578 | lo = add_layout("title") 1579 | lo.geometry = geo 1580 | lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", 1581 | osc_styles.vidtitleBar, 1582 | geo.x, geo.y-geo.h, geo.w, geo.y+geo.h) 1583 | 1584 | 1585 | -- Playback control buttons 1586 | geo = { x = osc_geo.x + padX + padwc_l, y = line2, an = 4, 1587 | w = buttonW, h = 36 - padY*2} 1588 | lo = add_layout("playpause") 1589 | lo.geometry = geo 1590 | lo.style = osc_styles.smallButtonsBar 1591 | 1592 | geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1593 | lo = add_layout("ch_prev") 1594 | lo.geometry = geo 1595 | lo.style = osc_styles.smallButtonsBar 1596 | 1597 | geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1598 | lo = add_layout("ch_next") 1599 | lo.geometry = geo 1600 | lo.style = osc_styles.smallButtonsBar 1601 | 1602 | -- Left timecode 1603 | geo = { x = geo.x + geo.w + padX + tcW, y = geo.y, an = 6, 1604 | w = tcW, h = geo.h } 1605 | lo = add_layout("tc_left") 1606 | lo.geometry = geo 1607 | lo.style = osc_styles.timecodesBar 1608 | 1609 | local sb_l = geo.x + padX 1610 | 1611 | -- Fullscreen button 1612 | geo = { x = osc_geo.x + osc_geo.w - buttonW - padX - padwc_r, y = geo.y, an = 4, 1613 | w = buttonW, h = geo.h } 1614 | lo = add_layout("tog_fs") 1615 | lo.geometry = geo 1616 | lo.style = osc_styles.smallButtonsBar 1617 | 1618 | -- START quality-menu 1619 | geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1620 | lo = add_layout("quality-menu") 1621 | lo.geometry = geo 1622 | lo.style = osc_styles.smallButtonsBar 1623 | -- END quality-menu 1624 | 1625 | -- Volume 1626 | geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1627 | lo = add_layout("volume") 1628 | lo.geometry = geo 1629 | lo.style = osc_styles.smallButtonsBar 1630 | 1631 | -- Track selection buttons 1632 | geo = { x = geo.x - tsW - padX, y = geo.y, an = geo.an, w = tsW, h = geo.h } 1633 | lo = add_layout("cy_sub") 1634 | lo.geometry = geo 1635 | lo.style = osc_styles.smallButtonsBar 1636 | 1637 | geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } 1638 | lo = add_layout("cy_audio") 1639 | lo.geometry = geo 1640 | lo.style = osc_styles.smallButtonsBar 1641 | 1642 | 1643 | -- Right timecode 1644 | geo = { x = geo.x - padX - tcW - 10, y = geo.y, an = geo.an, 1645 | w = tcW, h = geo.h } 1646 | lo = add_layout("tc_right") 1647 | lo.geometry = geo 1648 | lo.style = osc_styles.timecodesBar 1649 | 1650 | local sb_r = geo.x - padX 1651 | 1652 | 1653 | -- Seekbar 1654 | geo = { x = sb_l, y = geo.y, an = geo.an, 1655 | w = math.max(0, sb_r - sb_l), h = geo.h } 1656 | new_element("bgbar1", "box") 1657 | lo = add_layout("bgbar1") 1658 | 1659 | lo.geometry = geo 1660 | lo.layer = 15 1661 | lo.style = osc_styles.timecodesBar 1662 | lo.alpha[1] = 1663 | math.min(255, user_opts.boxalpha + (255 - user_opts.boxalpha)*0.8) 1664 | if not (user_opts["seekbarstyle"] == "bar") then 1665 | lo.box.radius = geo.h / 2 1666 | lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" 1667 | end 1668 | 1669 | lo = add_layout("seekbar") 1670 | lo.geometry = geo 1671 | lo.style = osc_styles.timecodesBar 1672 | lo.slider.border = 0 1673 | lo.slider.gap = 2 1674 | lo.slider.tooltip_style = osc_styles.timePosBar 1675 | lo.slider.tooltip_an = 5 1676 | lo.slider.stype = user_opts["seekbarstyle"] 1677 | lo.slider.rtype = user_opts["seekrangestyle"] 1678 | 1679 | if direction < 0 then 1680 | osc_param.video_margins.b = osc_geo.h / osc_param.playresy 1681 | else 1682 | osc_param.video_margins.t = osc_geo.h / osc_param.playresy 1683 | end 1684 | end 1685 | 1686 | layouts["bottombar"] = function() 1687 | bar_layout(-1) 1688 | end 1689 | 1690 | layouts["topbar"] = function() 1691 | bar_layout(1) 1692 | end 1693 | 1694 | -- Validate string type user options 1695 | function validate_user_opts() 1696 | if layouts[user_opts.layout] == nil then 1697 | msg.warn("Invalid setting \""..user_opts.layout.."\" for layout") 1698 | user_opts.layout = "bottombar" 1699 | end 1700 | 1701 | if user_opts.seekbarstyle ~= "bar" and 1702 | user_opts.seekbarstyle ~= "diamond" and 1703 | user_opts.seekbarstyle ~= "knob" then 1704 | msg.warn("Invalid setting \"" .. user_opts.seekbarstyle 1705 | .. "\" for seekbarstyle") 1706 | user_opts.seekbarstyle = "bar" 1707 | end 1708 | 1709 | if user_opts.seekrangestyle ~= "bar" and 1710 | user_opts.seekrangestyle ~= "line" and 1711 | user_opts.seekrangestyle ~= "slider" and 1712 | user_opts.seekrangestyle ~= "inverted" and 1713 | user_opts.seekrangestyle ~= "none" then 1714 | msg.warn("Invalid setting \"" .. user_opts.seekrangestyle 1715 | .. "\" for seekrangestyle") 1716 | user_opts.seekrangestyle = "inverted" 1717 | end 1718 | 1719 | if user_opts.seekrangestyle == "slider" and 1720 | user_opts.seekbarstyle == "bar" then 1721 | msg.warn("Using \"slider\" seekrangestyle together with \"bar\" seekbarstyle is not supported") 1722 | user_opts.seekrangestyle = "inverted" 1723 | end 1724 | 1725 | if user_opts.windowcontrols ~= "auto" and 1726 | user_opts.windowcontrols ~= "yes" and 1727 | user_opts.windowcontrols ~= "no" then 1728 | msg.warn("windowcontrols cannot be \"" .. 1729 | user_opts.windowcontrols .. "\". Ignoring.") 1730 | user_opts.windowcontrols = "auto" 1731 | end 1732 | if user_opts.windowcontrols_alignment ~= "right" and 1733 | user_opts.windowcontrols_alignment ~= "left" then 1734 | msg.warn("windowcontrols_alignment cannot be \"" .. 1735 | user_opts.windowcontrols_alignment .. "\". Ignoring.") 1736 | user_opts.windowcontrols_alignment = "right" 1737 | end 1738 | end 1739 | 1740 | function update_options(list) 1741 | validate_user_opts() 1742 | request_tick() 1743 | visibility_mode(user_opts.visibility, true) 1744 | update_duration_watch() 1745 | request_init() 1746 | end 1747 | 1748 | local UNICODE_MINUS = string.char(0xe2, 0x88, 0x92) -- UTF-8 for U+2212 MINUS SIGN 1749 | 1750 | -- OSC INIT 1751 | function osc_init() 1752 | msg.debug("osc_init") 1753 | 1754 | -- set canvas resolution according to display aspect and scaling setting 1755 | local baseResY = 720 1756 | local display_w, display_h, display_aspect = mp.get_osd_size() 1757 | local scale = 1 1758 | 1759 | if (mp.get_property("video") == "no") then -- dummy/forced window 1760 | scale = user_opts.scaleforcedwindow 1761 | elseif state.fullscreen then 1762 | scale = user_opts.scalefullscreen 1763 | else 1764 | scale = user_opts.scalewindowed 1765 | end 1766 | 1767 | if user_opts.vidscale then 1768 | osc_param.unscaled_y = baseResY 1769 | else 1770 | osc_param.unscaled_y = display_h 1771 | end 1772 | osc_param.playresy = osc_param.unscaled_y / scale 1773 | if (display_aspect > 0) then 1774 | osc_param.display_aspect = display_aspect 1775 | end 1776 | osc_param.playresx = osc_param.playresy * osc_param.display_aspect 1777 | 1778 | -- stop seeking with the slider to prevent skipping files 1779 | state.active_element = nil 1780 | 1781 | osc_param.video_margins = {l = 0, r = 0, t = 0, b = 0} 1782 | 1783 | elements = {} 1784 | 1785 | -- some often needed stuff 1786 | local pl_count = mp.get_property_number("playlist-count", 0) 1787 | local have_pl = (pl_count > 1) 1788 | local pl_pos = mp.get_property_number("playlist-pos", 0) + 1 1789 | local have_ch = (mp.get_property_number("chapters", 0) > 0) 1790 | local loop = mp.get_property("loop-playlist", "no") 1791 | 1792 | local ne 1793 | 1794 | -- title 1795 | ne = new_element("title", "button") 1796 | 1797 | ne.content = function () 1798 | local title = state.forced_title or 1799 | mp.command_native({"expand-text", user_opts.title}) 1800 | -- escape ASS, and strip newlines and trailing slashes 1801 | title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") 1802 | return not (title == "") and title or "mpv" 1803 | end 1804 | 1805 | ne.eventresponder["mbtn_left_up"] = function () 1806 | local title = mp.get_property_osd("media-title") 1807 | if (have_pl) then 1808 | title = string.format("[%d/%d] %s", countone(pl_pos - 1), 1809 | pl_count, title) 1810 | end 1811 | show_message(title) 1812 | end 1813 | 1814 | ne.eventresponder["mbtn_right_up"] = 1815 | function () show_message(mp.get_property_osd("filename")) end 1816 | 1817 | -- playlist buttons 1818 | 1819 | -- prev 1820 | ne = new_element("pl_prev", "button") 1821 | 1822 | ne.content = "\238\132\144" 1823 | ne.enabled = (pl_pos > 1) or (loop ~= "no") 1824 | ne.eventresponder["mbtn_left_up"] = 1825 | function () 1826 | mp.commandv("playlist-prev", "weak") 1827 | if user_opts.playlist_osd then 1828 | show_message(get_playlist(), 3) 1829 | end 1830 | end 1831 | ne.eventresponder["shift+mbtn_left_up"] = 1832 | function () show_message(get_playlist(), 3) end 1833 | ne.eventresponder["mbtn_right_up"] = 1834 | function () show_message(get_playlist(), 3) end 1835 | 1836 | --next 1837 | ne = new_element("pl_next", "button") 1838 | 1839 | ne.content = "\238\132\129" 1840 | ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no") 1841 | ne.eventresponder["mbtn_left_up"] = 1842 | function () 1843 | mp.commandv("playlist-next", "weak") 1844 | if user_opts.playlist_osd then 1845 | show_message(get_playlist(), 3) 1846 | end 1847 | end 1848 | ne.eventresponder["shift+mbtn_left_up"] = 1849 | function () show_message(get_playlist(), 3) end 1850 | ne.eventresponder["mbtn_right_up"] = 1851 | function () show_message(get_playlist(), 3) end 1852 | 1853 | 1854 | -- big buttons 1855 | 1856 | --playpause 1857 | ne = new_element("playpause", "button") 1858 | 1859 | ne.content = function () 1860 | if mp.get_property("pause") == "yes" then 1861 | return ("\238\132\129") 1862 | else 1863 | return ("\238\128\130") 1864 | end 1865 | end 1866 | ne.eventresponder["mbtn_left_up"] = 1867 | function () mp.commandv("cycle", "pause") end 1868 | 1869 | --skipback 1870 | ne = new_element("skipback", "button") 1871 | 1872 | ne.softrepeat = true 1873 | ne.content = "\238\128\132" 1874 | ne.eventresponder["mbtn_left_down"] = 1875 | function () mp.commandv("seek", -5, "relative", "keyframes") end 1876 | ne.eventresponder["shift+mbtn_left_down"] = 1877 | function () mp.commandv("frame-back-step") end 1878 | ne.eventresponder["mbtn_right_down"] = 1879 | function () mp.commandv("seek", -30, "relative", "keyframes") end 1880 | 1881 | --skipfrwd 1882 | ne = new_element("skipfrwd", "button") 1883 | 1884 | ne.softrepeat = true 1885 | ne.content = "\238\128\133" 1886 | ne.eventresponder["mbtn_left_down"] = 1887 | function () mp.commandv("seek", 10, "relative", "keyframes") end 1888 | ne.eventresponder["shift+mbtn_left_down"] = 1889 | function () mp.commandv("frame-step") end 1890 | ne.eventresponder["mbtn_right_down"] = 1891 | function () mp.commandv("seek", 60, "relative", "keyframes") end 1892 | 1893 | --ch_prev 1894 | ne = new_element("ch_prev", "button") 1895 | 1896 | ne.enabled = have_ch 1897 | ne.content = "\238\132\132" 1898 | ne.eventresponder["mbtn_left_up"] = 1899 | function () 1900 | mp.commandv("add", "chapter", -1) 1901 | if user_opts.chapters_osd then 1902 | show_message(get_chapterlist(), 3) 1903 | end 1904 | end 1905 | ne.eventresponder["shift+mbtn_left_up"] = 1906 | function () show_message(get_chapterlist(), 3) end 1907 | ne.eventresponder["mbtn_right_up"] = 1908 | function () show_message(get_chapterlist(), 3) end 1909 | 1910 | --ch_next 1911 | ne = new_element("ch_next", "button") 1912 | 1913 | ne.enabled = have_ch 1914 | ne.content = "\238\132\133" 1915 | ne.eventresponder["mbtn_left_up"] = 1916 | function () 1917 | mp.commandv("add", "chapter", 1) 1918 | if user_opts.chapters_osd then 1919 | show_message(get_chapterlist(), 3) 1920 | end 1921 | end 1922 | ne.eventresponder["shift+mbtn_left_up"] = 1923 | function () show_message(get_chapterlist(), 3) end 1924 | ne.eventresponder["mbtn_right_up"] = 1925 | function () show_message(get_chapterlist(), 3) end 1926 | 1927 | -- 1928 | update_tracklist() 1929 | 1930 | --cy_audio 1931 | ne = new_element("cy_audio", "button") 1932 | 1933 | ne.enabled = (#tracks_osc.audio > 0) 1934 | ne.content = function () 1935 | local aid = "–" 1936 | if not (get_track("audio") == 0) then 1937 | aid = get_track("audio") 1938 | end 1939 | return ("\238\132\134" .. osc_styles.smallButtonsLlabel 1940 | .. " " .. aid .. "/" .. #tracks_osc.audio) 1941 | end 1942 | ne.eventresponder["mbtn_left_up"] = 1943 | function () set_track("audio", 1) end 1944 | ne.eventresponder["mbtn_right_up"] = 1945 | function () set_track("audio", -1) end 1946 | ne.eventresponder["shift+mbtn_left_down"] = 1947 | function () show_message(get_tracklist("audio"), 2) end 1948 | ne.eventresponder["wheel_down_press"] = 1949 | function () set_track("audio", 1) end 1950 | ne.eventresponder["wheel_up_press"] = 1951 | function () set_track("audio", -1) end 1952 | 1953 | --cy_sub 1954 | ne = new_element("cy_sub", "button") 1955 | 1956 | ne.enabled = (#tracks_osc.sub > 0) 1957 | ne.content = function () 1958 | local sid = "–" 1959 | if not (get_track("sub") == 0) then 1960 | sid = get_track("sub") 1961 | end 1962 | return ("\238\132\135" .. osc_styles.smallButtonsLlabel 1963 | .. " " .. sid .. "/" .. #tracks_osc.sub) 1964 | end 1965 | ne.eventresponder["mbtn_left_up"] = 1966 | function () set_track("sub", 1) end 1967 | ne.eventresponder["mbtn_right_up"] = 1968 | function () set_track("sub", -1) end 1969 | ne.eventresponder["shift+mbtn_left_down"] = 1970 | function () show_message(get_tracklist("sub"), 2) end 1971 | ne.eventresponder["wheel_down_press"] = 1972 | function () set_track("sub", 1) end 1973 | ne.eventresponder["wheel_up_press"] = 1974 | function () set_track("sub", -1) end 1975 | 1976 | --tog_fs 1977 | ne = new_element("tog_fs", "button") 1978 | ne.content = function () 1979 | if (state.fullscreen) then 1980 | return ("\238\132\137") 1981 | else 1982 | return ("\238\132\136") 1983 | end 1984 | end 1985 | ne.eventresponder["mbtn_left_up"] = 1986 | function () mp.commandv("cycle", "fullscreen") end 1987 | 1988 | --seekbar 1989 | ne = new_element("seekbar", "slider") 1990 | 1991 | ne.enabled = not (mp.get_property("percent-pos") == nil) 1992 | state.slider_element = ne.enabled and ne or nil -- used for forced_title 1993 | ne.slider.markerF = function () 1994 | local duration = mp.get_property_number("duration", nil) 1995 | if not (duration == nil) then 1996 | local chapters = mp.get_property_native("chapter-list", {}) 1997 | local markers = {} 1998 | for n = 1, #chapters do 1999 | markers[n] = (chapters[n].time / duration * 100) 2000 | end 2001 | return markers 2002 | else 2003 | return {} 2004 | end 2005 | end 2006 | ne.slider.posF = 2007 | function () return mp.get_property_number("percent-pos", nil) end 2008 | ne.slider.tooltipF = function (pos) 2009 | local duration = mp.get_property_number("duration", nil) 2010 | if not ((duration == nil) or (pos == nil)) then 2011 | possec = duration * (pos / 100) 2012 | return mp.format_time(possec) 2013 | else 2014 | return "" 2015 | end 2016 | end 2017 | ne.slider.seekRangesF = function() 2018 | if user_opts.seekrangestyle == "none" then 2019 | return nil 2020 | end 2021 | local cache_state = state.cache_state 2022 | if not cache_state then 2023 | return nil 2024 | end 2025 | local duration = mp.get_property_number("duration", nil) 2026 | if (duration == nil) or duration <= 0 then 2027 | return nil 2028 | end 2029 | local ranges = cache_state["seekable-ranges"] 2030 | if #ranges == 0 then 2031 | return nil 2032 | end 2033 | local nranges = {} 2034 | for _, range in pairs(ranges) do 2035 | nranges[#nranges + 1] = { 2036 | ["start"] = 100 * range["start"] / duration, 2037 | ["end"] = 100 * range["end"] / duration, 2038 | } 2039 | end 2040 | return nranges 2041 | end 2042 | ne.eventresponder["mouse_move"] = --keyframe seeking when mouse is dragged 2043 | function (element) 2044 | -- mouse move events may pile up during seeking and may still get 2045 | -- sent when the user is done seeking, so we need to throw away 2046 | -- identical seeks 2047 | local seekto = get_slider_value(element) 2048 | if (element.state.lastseek == nil) or 2049 | (not (element.state.lastseek == seekto)) then 2050 | local flags = "absolute-percent" 2051 | if not user_opts.seekbarkeyframes then 2052 | flags = flags .. "+exact" 2053 | end 2054 | mp.commandv("seek", seekto, flags) 2055 | element.state.lastseek = seekto 2056 | end 2057 | 2058 | end 2059 | ne.eventresponder["mbtn_left_down"] = --exact seeks on single clicks 2060 | function (element) mp.commandv("seek", get_slider_value(element), 2061 | "absolute-percent", "exact") end 2062 | ne.eventresponder["reset"] = 2063 | function (element) element.state.lastseek = nil end 2064 | ne.eventresponder["wheel_up_press"] = 2065 | function () mp.commandv("osd-auto", "seek", 10) end 2066 | ne.eventresponder["wheel_down_press"] = 2067 | function () mp.commandv("osd-auto", "seek", -10) end 2068 | 2069 | 2070 | -- tc_left (current pos) 2071 | ne = new_element("tc_left", "button") 2072 | 2073 | ne.content = function () 2074 | if (state.tc_ms) then 2075 | return (mp.get_property_osd("playback-time/full")) 2076 | else 2077 | return (mp.get_property_osd("playback-time")) 2078 | end 2079 | end 2080 | ne.eventresponder["mbtn_left_up"] = function () 2081 | state.tc_ms = not state.tc_ms 2082 | request_init() 2083 | end 2084 | 2085 | -- tc_right (total/remaining time) 2086 | ne = new_element("tc_right", "button") 2087 | 2088 | ne.visible = (mp.get_property_number("duration", 0) > 0) 2089 | ne.content = function () 2090 | if (state.rightTC_trem) then 2091 | local minus = user_opts.unicodeminus and UNICODE_MINUS or "-" 2092 | local property = user_opts.remaining_playtime and "playtime-remaining" 2093 | or "time-remaining" 2094 | if state.tc_ms then 2095 | return (minus..mp.get_property_osd(property .. "/full")) 2096 | else 2097 | return (minus..mp.get_property_osd(property)) 2098 | end 2099 | else 2100 | if state.tc_ms then 2101 | return (mp.get_property_osd("duration/full")) 2102 | else 2103 | return (mp.get_property_osd("duration")) 2104 | end 2105 | end 2106 | end 2107 | ne.eventresponder["mbtn_left_up"] = 2108 | function () state.rightTC_trem = not state.rightTC_trem end 2109 | 2110 | -- cache 2111 | ne = new_element("cache", "button") 2112 | 2113 | ne.content = function () 2114 | local cache_state = state.cache_state 2115 | if not (cache_state and cache_state["seekable-ranges"] and 2116 | #cache_state["seekable-ranges"] > 0) then 2117 | -- probably not a network stream 2118 | return "" 2119 | end 2120 | local dmx_cache = cache_state and cache_state["cache-duration"] 2121 | local thresh = math.min(state.dmx_cache * 0.05, 5) -- 5% or 5s 2122 | if dmx_cache and math.abs(dmx_cache - state.dmx_cache) >= thresh then 2123 | state.dmx_cache = dmx_cache 2124 | else 2125 | dmx_cache = state.dmx_cache 2126 | end 2127 | local min = math.floor(dmx_cache / 60) 2128 | local sec = math.floor(dmx_cache % 60) -- don't round e.g. 59.9 to 60 2129 | return "Cache: " .. (min > 0 and 2130 | string.format("%sm%02.0fs", min, sec) or 2131 | string.format("%3.0fs", sec)) 2132 | end 2133 | 2134 | -- START quality-menu 2135 | ne = new_element("quality-menu", "button") 2136 | ne.content = function() 2137 | return ("≚") 2138 | end 2139 | ne.eventresponder["mbtn_left_up"] = 2140 | function () mp.commandv("script-message", "video_formats_toggle") end 2141 | ne.eventresponder["mbtn_right_up"] = 2142 | function () mp.commandv("script-message", "audio_formats_toggle") end 2143 | -- END quality-menu 2144 | 2145 | -- volume 2146 | ne = new_element("volume", "button") 2147 | 2148 | ne.content = function() 2149 | local volume = mp.get_property_number("volume", 0) 2150 | local mute = mp.get_property_native("mute") 2151 | local volicon = {"\238\132\139", "\238\132\140", 2152 | "\238\132\141", "\238\132\142"} 2153 | if volume == 0 or mute then 2154 | return "\238\132\138" 2155 | else 2156 | return volicon[math.min(4,math.ceil(volume / (100/3)))] 2157 | end 2158 | end 2159 | ne.eventresponder["mbtn_left_up"] = 2160 | function () mp.commandv("cycle", "mute") end 2161 | 2162 | ne.eventresponder["wheel_up_press"] = 2163 | function () mp.commandv("osd-auto", "add", "volume", 5) end 2164 | ne.eventresponder["wheel_down_press"] = 2165 | function () mp.commandv("osd-auto", "add", "volume", -5) end 2166 | 2167 | 2168 | -- load layout 2169 | layouts[user_opts.layout]() 2170 | 2171 | -- load window controls 2172 | if window_controls_enabled() then 2173 | window_controls(user_opts.layout == "topbar") 2174 | end 2175 | 2176 | --do something with the elements 2177 | prepare_elements() 2178 | 2179 | update_margins() 2180 | end 2181 | 2182 | function reset_margins() 2183 | if state.using_video_margins then 2184 | for _, opt in ipairs(margins_opts) do 2185 | mp.set_property_number(opt[2], 0.0) 2186 | end 2187 | state.using_video_margins = false 2188 | end 2189 | end 2190 | 2191 | function update_margins() 2192 | local margins = osc_param.video_margins 2193 | 2194 | -- Don't use margins if it's visible only temporarily. 2195 | if (not state.osc_visible) or (get_hidetimeout() >= 0) or 2196 | (state.fullscreen and not user_opts.showfullscreen) or 2197 | (not state.fullscreen and not user_opts.showwindowed) 2198 | then 2199 | margins = {l = 0, r = 0, t = 0, b = 0} 2200 | end 2201 | 2202 | if user_opts.boxvideo then 2203 | -- check whether any margin option has a non-default value 2204 | local margins_used = false 2205 | 2206 | if not state.using_video_margins then 2207 | for _, opt in ipairs(margins_opts) do 2208 | if mp.get_property_number(opt[2], 0.0) ~= 0.0 then 2209 | margins_used = true 2210 | end 2211 | end 2212 | end 2213 | 2214 | if not margins_used then 2215 | for _, opt in ipairs(margins_opts) do 2216 | local v = margins[opt[1]] 2217 | if (v ~= 0) or state.using_video_margins then 2218 | mp.set_property_number(opt[2], v) 2219 | state.using_video_margins = true 2220 | end 2221 | end 2222 | end 2223 | else 2224 | reset_margins() 2225 | end 2226 | 2227 | if utils.shared_script_property_set then 2228 | utils.shared_script_property_set("osc-margins", 2229 | string.format("%f,%f,%f,%f", margins.l, margins.r, margins.t, margins.b)) 2230 | end 2231 | mp.set_property_native("user-data/osc/margins", margins) 2232 | end 2233 | 2234 | function shutdown() 2235 | reset_margins() 2236 | if utils.shared_script_property_set then 2237 | utils.shared_script_property_set("osc-margins", nil) 2238 | end 2239 | if mp.del_property then 2240 | mp.del_property("user-data/osc") 2241 | end 2242 | end 2243 | 2244 | -- 2245 | -- Other important stuff 2246 | -- 2247 | 2248 | 2249 | function show_osc() 2250 | -- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding 2251 | if not state.enabled then return end 2252 | 2253 | msg.trace("show_osc") 2254 | --remember last time of invocation (mouse move) 2255 | state.showtime = mp.get_time() 2256 | 2257 | osc_visible(true) 2258 | 2259 | if (user_opts.fadeduration > 0) then 2260 | state.anitype = nil 2261 | end 2262 | end 2263 | 2264 | function hide_osc() 2265 | msg.trace("hide_osc") 2266 | if not state.enabled then 2267 | -- typically hide happens at render() from tick(), but now tick() is 2268 | -- no-op and won't render again to remove the osc, so do that manually. 2269 | state.osc_visible = false 2270 | render_wipe() 2271 | elseif (user_opts.fadeduration > 0) then 2272 | if not(state.osc_visible == false) then 2273 | state.anitype = "out" 2274 | request_tick() 2275 | end 2276 | else 2277 | osc_visible(false) 2278 | end 2279 | end 2280 | 2281 | function osc_visible(visible) 2282 | if state.osc_visible ~= visible then 2283 | state.osc_visible = visible 2284 | update_margins() 2285 | end 2286 | request_tick() 2287 | end 2288 | 2289 | function pause_state(name, enabled) 2290 | state.paused = enabled 2291 | request_tick() 2292 | end 2293 | 2294 | function cache_state(name, st) 2295 | state.cache_state = st 2296 | request_tick() 2297 | end 2298 | 2299 | -- Request that tick() is called (which typically re-renders the OSC). 2300 | -- The tick is then either executed immediately, or rate-limited if it was 2301 | -- called a small time ago. 2302 | function request_tick() 2303 | if state.tick_timer == nil then 2304 | state.tick_timer = mp.add_timeout(0, tick) 2305 | end 2306 | 2307 | if not state.tick_timer:is_enabled() then 2308 | local now = mp.get_time() 2309 | local timeout = tick_delay - (now - state.tick_last_time) 2310 | if timeout < 0 then 2311 | timeout = 0 2312 | end 2313 | state.tick_timer.timeout = timeout 2314 | state.tick_timer:resume() 2315 | end 2316 | end 2317 | 2318 | function mouse_leave() 2319 | if get_hidetimeout() >= 0 then 2320 | hide_osc() 2321 | end 2322 | -- reset mouse position 2323 | state.last_mouseX, state.last_mouseY = nil, nil 2324 | state.mouse_in_window = false 2325 | end 2326 | 2327 | function request_init() 2328 | state.initREQ = true 2329 | request_tick() 2330 | end 2331 | 2332 | -- Like request_init(), but also request an immediate update 2333 | function request_init_resize() 2334 | request_init() 2335 | -- ensure immediate update 2336 | state.tick_timer:kill() 2337 | state.tick_timer.timeout = 0 2338 | state.tick_timer:resume() 2339 | end 2340 | 2341 | function render_wipe() 2342 | msg.trace("render_wipe()") 2343 | state.osd.data = "" -- allows set_osd to immediately update on enable 2344 | state.osd:remove() 2345 | end 2346 | 2347 | function render() 2348 | msg.trace("rendering") 2349 | local current_screen_sizeX, current_screen_sizeY, aspect = mp.get_osd_size() 2350 | local mouseX, mouseY = get_virt_mouse_pos() 2351 | local now = mp.get_time() 2352 | 2353 | -- check if display changed, if so request reinit 2354 | if not (state.mp_screen_sizeX == current_screen_sizeX 2355 | and state.mp_screen_sizeY == current_screen_sizeY) then 2356 | 2357 | request_init_resize() 2358 | 2359 | state.mp_screen_sizeX = current_screen_sizeX 2360 | state.mp_screen_sizeY = current_screen_sizeY 2361 | end 2362 | 2363 | -- init management 2364 | if state.active_element then 2365 | -- mouse is held down on some element - keep ticking and ignore initReq 2366 | -- till it's released, or else the mouse-up (click) will misbehave or 2367 | -- get ignored. that's because osc_init() recreates the osc elements, 2368 | -- but mouse handling depends on the elements staying unmodified 2369 | -- between mouse-down and mouse-up (using the index active_element). 2370 | request_tick() 2371 | elseif state.initREQ then 2372 | osc_init() 2373 | state.initREQ = false 2374 | 2375 | -- store initial mouse position 2376 | if (state.last_mouseX == nil or state.last_mouseY == nil) 2377 | and not (mouseX == nil or mouseY == nil) then 2378 | 2379 | state.last_mouseX, state.last_mouseY = mouseX, mouseY 2380 | end 2381 | end 2382 | 2383 | 2384 | -- fade animation 2385 | if not(state.anitype == nil) then 2386 | 2387 | if (state.anistart == nil) then 2388 | state.anistart = now 2389 | end 2390 | 2391 | if (now < state.anistart + (user_opts.fadeduration/1000)) then 2392 | 2393 | if (state.anitype == "in") then --fade in 2394 | osc_visible(true) 2395 | state.animation = scale_value(state.anistart, 2396 | (state.anistart + (user_opts.fadeduration/1000)), 2397 | 255, 0, now) 2398 | elseif (state.anitype == "out") then --fade out 2399 | state.animation = scale_value(state.anistart, 2400 | (state.anistart + (user_opts.fadeduration/1000)), 2401 | 0, 255, now) 2402 | end 2403 | 2404 | else 2405 | if (state.anitype == "out") then 2406 | osc_visible(false) 2407 | end 2408 | kill_animation() 2409 | end 2410 | else 2411 | kill_animation() 2412 | end 2413 | 2414 | --mouse show/hide area 2415 | for k,cords in pairs(osc_param.areas["showhide"]) do 2416 | set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide") 2417 | end 2418 | if osc_param.areas["showhide_wc"] then 2419 | for k,cords in pairs(osc_param.areas["showhide_wc"]) do 2420 | set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide_wc") 2421 | end 2422 | else 2423 | set_virt_mouse_area(0, 0, 0, 0, "showhide_wc") 2424 | end 2425 | do_enable_keybindings() 2426 | 2427 | --mouse input area 2428 | local mouse_over_osc = false 2429 | 2430 | for _,cords in ipairs(osc_param.areas["input"]) do 2431 | if state.osc_visible then -- activate only when OSC is actually visible 2432 | set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "input") 2433 | end 2434 | if state.osc_visible ~= state.input_enabled then 2435 | if state.osc_visible then 2436 | mp.enable_key_bindings("input") 2437 | else 2438 | mp.disable_key_bindings("input") 2439 | end 2440 | state.input_enabled = state.osc_visible 2441 | end 2442 | 2443 | if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then 2444 | mouse_over_osc = true 2445 | end 2446 | end 2447 | 2448 | if osc_param.areas["window-controls"] then 2449 | for _,cords in ipairs(osc_param.areas["window-controls"]) do 2450 | if state.osc_visible then -- activate only when OSC is actually visible 2451 | set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "window-controls") 2452 | end 2453 | if state.osc_visible ~= state.windowcontrols_buttons then 2454 | if state.osc_visible then 2455 | mp.enable_key_bindings("window-controls") 2456 | else 2457 | mp.disable_key_bindings("window-controls") 2458 | end 2459 | state.windowcontrols_buttons = state.osc_visible 2460 | end 2461 | 2462 | if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then 2463 | mouse_over_osc = true 2464 | end 2465 | end 2466 | end 2467 | 2468 | if osc_param.areas["window-controls-title"] then 2469 | for _,cords in ipairs(osc_param.areas["window-controls-title"]) do 2470 | if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then 2471 | mouse_over_osc = true 2472 | end 2473 | end 2474 | end 2475 | 2476 | -- autohide 2477 | if not (state.showtime == nil) and (get_hidetimeout() >= 0) then 2478 | local timeout = state.showtime + (get_hidetimeout()/1000) - now 2479 | if timeout <= 0 then 2480 | if (state.active_element == nil) and not (mouse_over_osc) then 2481 | hide_osc() 2482 | end 2483 | else 2484 | -- the timer is only used to recheck the state and to possibly run 2485 | -- the code above again 2486 | if not state.hide_timer then 2487 | state.hide_timer = mp.add_timeout(0, tick) 2488 | end 2489 | state.hide_timer.timeout = timeout 2490 | -- re-arm 2491 | state.hide_timer:kill() 2492 | state.hide_timer:resume() 2493 | end 2494 | end 2495 | 2496 | 2497 | -- actual rendering 2498 | local ass = assdraw.ass_new() 2499 | 2500 | -- Messages 2501 | render_message(ass) 2502 | 2503 | -- actual OSC 2504 | if state.osc_visible then 2505 | render_elements(ass) 2506 | end 2507 | 2508 | -- submit 2509 | set_osd(osc_param.playresy * osc_param.display_aspect, 2510 | osc_param.playresy, ass.text) 2511 | end 2512 | 2513 | -- 2514 | -- Eventhandling 2515 | -- 2516 | 2517 | local function element_has_action(element, action) 2518 | return element and element.eventresponder and 2519 | element.eventresponder[action] 2520 | end 2521 | 2522 | function process_event(source, what) 2523 | local action = string.format("%s%s", source, 2524 | what and ("_" .. what) or "") 2525 | 2526 | if what == "down" or what == "press" then 2527 | 2528 | for n = 1, #elements do 2529 | 2530 | if mouse_hit(elements[n]) and 2531 | elements[n].eventresponder and 2532 | (elements[n].eventresponder[source .. "_up"] or 2533 | elements[n].eventresponder[action]) then 2534 | 2535 | if what == "down" then 2536 | state.active_element = n 2537 | state.active_event_source = source 2538 | end 2539 | -- fire the down or press event if the element has one 2540 | if element_has_action(elements[n], action) then 2541 | elements[n].eventresponder[action](elements[n]) 2542 | end 2543 | 2544 | end 2545 | end 2546 | 2547 | elseif what == "up" then 2548 | 2549 | if elements[state.active_element] then 2550 | local n = state.active_element 2551 | 2552 | if n == 0 then 2553 | --click on background (does not work) 2554 | elseif element_has_action(elements[n], action) and 2555 | mouse_hit(elements[n]) then 2556 | 2557 | elements[n].eventresponder[action](elements[n]) 2558 | end 2559 | 2560 | --reset active element 2561 | if element_has_action(elements[n], "reset") then 2562 | elements[n].eventresponder["reset"](elements[n]) 2563 | end 2564 | 2565 | end 2566 | state.active_element = nil 2567 | state.mouse_down_counter = 0 2568 | 2569 | elseif source == "mouse_move" then 2570 | 2571 | state.mouse_in_window = true 2572 | 2573 | local mouseX, mouseY = get_virt_mouse_pos() 2574 | if (user_opts.minmousemove == 0) or 2575 | (not ((state.last_mouseX == nil) or (state.last_mouseY == nil)) and 2576 | ((math.abs(mouseX - state.last_mouseX) >= user_opts.minmousemove) 2577 | or (math.abs(mouseY - state.last_mouseY) >= user_opts.minmousemove) 2578 | ) 2579 | ) then 2580 | show_osc() 2581 | end 2582 | state.last_mouseX, state.last_mouseY = mouseX, mouseY 2583 | 2584 | local n = state.active_element 2585 | if element_has_action(elements[n], action) then 2586 | elements[n].eventresponder[action](elements[n]) 2587 | end 2588 | end 2589 | 2590 | -- ensure rendering after any (mouse) event - icons could change etc 2591 | request_tick() 2592 | end 2593 | 2594 | 2595 | local logo_lines = { 2596 | -- White border 2597 | "{\\c&HE5E5E5&\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 {\\p0}", 2598 | -- Purple fill 2599 | "{\\c&H682167&\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42{\\p0}", 2600 | -- Darker fill 2601 | "{\\c&H430142&\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}", 2602 | -- White fill 2603 | "{\\c&HDDDBDD&\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}", 2604 | -- Triangle 2605 | "{\\c&H691F69&\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}", 2606 | } 2607 | 2608 | local santa_hat_lines = { 2609 | -- Pompoms 2610 | "{\\c&HC0C0C0&\\p6}m 500 -323 b 491 -322 481 -318 475 -311 465 -312 456 -319 446 -318 434 -314 427 -304 417 -297 410 -290 404 -282 395 -278 390 -274 387 -267 381 -265 377 -261 379 -254 384 -253 397 -244 409 -232 425 -228 437 -228 446 -218 457 -217 462 -216 466 -213 468 -209 471 -205 477 -203 482 -206 491 -211 499 -217 508 -222 532 -235 556 -249 576 -267 584 -272 584 -284 578 -290 569 -305 550 -312 533 -309 523 -310 515 -316 507 -321 505 -323 503 -323 500 -323{\\p0}", 2611 | "{\\c&HE0E0E0&\\p6}m 315 -260 b 286 -258 259 -240 246 -215 235 -210 222 -215 211 -211 204 -188 177 -176 172 -151 170 -139 163 -128 154 -121 143 -103 141 -81 143 -60 139 -46 125 -34 129 -17 132 -1 134 16 142 30 145 56 161 80 181 96 196 114 210 133 231 144 266 153 303 138 328 115 373 79 401 28 423 -24 446 -73 465 -123 483 -174 487 -199 467 -225 442 -227 421 -232 402 -242 384 -254 364 -259 342 -250 322 -260 320 -260 317 -261 315 -260{\\p0}", 2612 | -- Main cap 2613 | "{\\c&H0000F0&\\p6}m 1151 -523 b 1016 -516 891 -458 769 -406 693 -369 624 -319 561 -262 526 -252 465 -235 479 -187 502 -147 551 -135 588 -111 1115 165 1379 232 1909 761 1926 800 1952 834 1987 858 2020 883 2053 912 2065 952 2088 1000 2146 962 2139 919 2162 836 2156 747 2143 662 2131 615 2116 567 2122 517 2120 410 2090 306 2089 199 2092 147 2071 99 2034 64 1987 5 1928 -41 1869 -86 1777 -157 1712 -256 1629 -337 1578 -389 1521 -436 1461 -476 1407 -509 1343 -507 1284 -515 1240 -519 1195 -521 1151 -523{\\p0}", 2614 | -- Cap shadow 2615 | "{\\c&H0000AA&\\p6}m 1657 248 b 1658 254 1659 261 1660 267 1669 276 1680 284 1689 293 1695 302 1700 311 1707 320 1716 325 1726 330 1735 335 1744 347 1752 360 1761 371 1753 352 1754 331 1753 311 1751 237 1751 163 1751 90 1752 64 1752 37 1767 14 1778 -3 1785 -24 1786 -45 1786 -60 1786 -77 1774 -87 1760 -96 1750 -78 1751 -65 1748 -37 1750 -8 1750 20 1734 78 1715 134 1699 192 1694 211 1689 231 1676 246 1671 251 1661 255 1657 248 m 1909 541 b 1914 542 1922 549 1917 539 1919 520 1921 502 1919 483 1918 458 1917 433 1915 407 1930 373 1942 338 1947 301 1952 270 1954 238 1951 207 1946 214 1947 229 1945 239 1939 278 1936 318 1924 356 1923 362 1913 382 1912 364 1906 301 1904 237 1891 175 1887 150 1892 126 1892 101 1892 68 1893 35 1888 2 1884 -9 1871 -20 1859 -14 1851 -6 1854 9 1854 20 1855 58 1864 95 1873 132 1883 179 1894 225 1899 273 1908 362 1910 451 1909 541{\\p0}", 2616 | -- Brim and tip pompom 2617 | "{\\c&HF8F8F8&\\p6}m 626 -191 b 565 -155 486 -196 428 -151 387 -115 327 -101 304 -47 273 2 267 59 249 113 219 157 217 213 215 265 217 309 260 302 285 283 373 264 465 264 555 257 608 252 655 292 709 287 759 294 816 276 863 298 903 340 972 324 1012 367 1061 394 1125 382 1167 424 1213 462 1268 482 1322 506 1385 546 1427 610 1479 662 1510 690 1534 725 1566 752 1611 796 1664 830 1703 880 1740 918 1747 986 1805 1005 1863 991 1897 932 1916 880 1914 823 1945 777 1961 725 1979 673 1957 622 1938 575 1912 534 1862 515 1836 473 1790 417 1755 351 1697 305 1658 266 1633 216 1593 176 1574 138 1539 116 1497 110 1448 101 1402 77 1371 37 1346 -16 1295 15 1254 6 1211 -27 1170 -62 1121 -86 1072 -104 1027 -128 976 -133 914 -130 851 -137 794 -162 740 -181 679 -168 626 -191 m 2051 917 b 1971 932 1929 1017 1919 1091 1912 1149 1923 1214 1970 1254 2000 1279 2027 1314 2066 1325 2139 1338 2212 1295 2254 1238 2281 1203 2287 1158 2282 1116 2292 1061 2273 1006 2229 970 2206 941 2167 938 2138 918{\\p0}", 2618 | } 2619 | 2620 | -- called by mpv on every frame 2621 | function tick() 2622 | if state.marginsREQ == true then 2623 | update_margins() 2624 | state.marginsREQ = false 2625 | end 2626 | 2627 | if (not state.enabled) then return end 2628 | 2629 | if (state.idle) then 2630 | 2631 | -- render idle message 2632 | msg.trace("idle message") 2633 | local _, _, display_aspect = mp.get_osd_size() 2634 | if display_aspect == 0 then 2635 | return 2636 | end 2637 | local display_h = 360 2638 | local display_w = display_h * display_aspect 2639 | -- logo is rendered at 2^(6-1) = 32 times resolution with size 1800x1800 2640 | local icon_x, icon_y = (display_w - 1800 / 32) / 2, 140 2641 | local line_prefix = ("{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}"):format(icon_x, icon_y) 2642 | 2643 | local ass = assdraw.ass_new() 2644 | -- mpv logo 2645 | if user_opts.idlescreen then 2646 | for i, line in ipairs(logo_lines) do 2647 | ass:new_event() 2648 | ass:append(line_prefix .. line) 2649 | end 2650 | end 2651 | 2652 | -- Santa hat 2653 | if is_december and user_opts.idlescreen and not user_opts.greenandgrumpy then 2654 | for i, line in ipairs(santa_hat_lines) do 2655 | ass:new_event() 2656 | ass:append(line_prefix .. line) 2657 | end 2658 | end 2659 | 2660 | if user_opts.idlescreen then 2661 | ass:new_event() 2662 | ass:pos(display_w / 2, icon_y + 65) 2663 | ass:an(8) 2664 | ass:append("Drop files or URLs to play here.") 2665 | end 2666 | set_osd(display_w, display_h, ass.text) 2667 | 2668 | if state.showhide_enabled then 2669 | mp.disable_key_bindings("showhide") 2670 | mp.disable_key_bindings("showhide_wc") 2671 | state.showhide_enabled = false 2672 | end 2673 | 2674 | 2675 | elseif (state.fullscreen and user_opts.showfullscreen) 2676 | or (not state.fullscreen and user_opts.showwindowed) then 2677 | 2678 | -- render the OSC 2679 | render() 2680 | else 2681 | -- Flush OSD 2682 | render_wipe() 2683 | end 2684 | 2685 | state.tick_last_time = mp.get_time() 2686 | 2687 | if state.anitype ~= nil then 2688 | -- state.anistart can be nil - animation should now start, or it can 2689 | -- be a timestamp when it started. state.idle has no animation. 2690 | if not state.idle and 2691 | (not state.anistart or 2692 | mp.get_time() < 1 + state.anistart + user_opts.fadeduration/1000) 2693 | then 2694 | -- animating or starting, or still within 1s past the deadline 2695 | request_tick() 2696 | else 2697 | kill_animation() 2698 | end 2699 | end 2700 | end 2701 | 2702 | function do_enable_keybindings() 2703 | if state.enabled then 2704 | if not state.showhide_enabled then 2705 | mp.enable_key_bindings("showhide", "allow-vo-dragging+allow-hide-cursor") 2706 | mp.enable_key_bindings("showhide_wc", "allow-vo-dragging+allow-hide-cursor") 2707 | end 2708 | state.showhide_enabled = true 2709 | end 2710 | end 2711 | 2712 | function enable_osc(enable) 2713 | state.enabled = enable 2714 | if enable then 2715 | do_enable_keybindings() 2716 | else 2717 | hide_osc() -- acts immediately when state.enabled == false 2718 | if state.showhide_enabled then 2719 | mp.disable_key_bindings("showhide") 2720 | mp.disable_key_bindings("showhide_wc") 2721 | end 2722 | state.showhide_enabled = false 2723 | end 2724 | end 2725 | 2726 | -- duration is observed for the sole purpose of updating chapter markers 2727 | -- positions. live streams with chapters are very rare, and the update is also 2728 | -- expensive (with request_init), so it's only observed when we have chapters 2729 | -- and the user didn't disable the livemarkers option (update_duration_watch). 2730 | function on_duration() request_init() end 2731 | 2732 | local duration_watched = false 2733 | function update_duration_watch() 2734 | local want_watch = user_opts.livemarkers and 2735 | (mp.get_property_number("chapters", 0) or 0) > 0 and 2736 | true or false -- ensure it's a boolean 2737 | 2738 | if (want_watch ~= duration_watched) then 2739 | if want_watch then 2740 | mp.observe_property("duration", nil, on_duration) 2741 | else 2742 | mp.unobserve_property(on_duration) 2743 | end 2744 | duration_watched = want_watch 2745 | end 2746 | end 2747 | 2748 | validate_user_opts() 2749 | update_duration_watch() 2750 | 2751 | mp.register_event("shutdown", shutdown) 2752 | mp.register_event("start-file", request_init) 2753 | mp.observe_property("track-list", nil, request_init) 2754 | mp.observe_property("playlist", nil, request_init) 2755 | mp.observe_property("chapter-list", "native", function(_, list) 2756 | list = list or {} -- safety, shouldn't return nil 2757 | table.sort(list, function(a, b) return a.time < b.time end) 2758 | state.chapter_list = list 2759 | update_duration_watch() 2760 | request_init() 2761 | end) 2762 | 2763 | mp.register_script_message("osc-message", show_message) 2764 | mp.register_script_message("osc-chapterlist", function(dur) 2765 | show_message(get_chapterlist(), dur) 2766 | end) 2767 | mp.register_script_message("osc-playlist", function(dur) 2768 | show_message(get_playlist(), dur) 2769 | end) 2770 | mp.register_script_message("osc-tracklist", function(dur) 2771 | local msg = {} 2772 | for k,v in pairs(nicetypes) do 2773 | table.insert(msg, get_tracklist(k)) 2774 | end 2775 | show_message(table.concat(msg, '\n\n'), dur) 2776 | end) 2777 | 2778 | mp.observe_property("fullscreen", "bool", 2779 | function(name, val) 2780 | state.fullscreen = val 2781 | state.marginsREQ = true 2782 | request_init_resize() 2783 | end 2784 | ) 2785 | mp.observe_property("border", "bool", 2786 | function(name, val) 2787 | state.border = val 2788 | request_init_resize() 2789 | end 2790 | ) 2791 | mp.observe_property("window-maximized", "bool", 2792 | function(name, val) 2793 | state.maximized = val 2794 | request_init_resize() 2795 | end 2796 | ) 2797 | mp.observe_property("idle-active", "bool", 2798 | function(name, val) 2799 | state.idle = val 2800 | request_tick() 2801 | end 2802 | ) 2803 | mp.observe_property("pause", "bool", pause_state) 2804 | mp.observe_property("demuxer-cache-state", "native", cache_state) 2805 | mp.observe_property("vo-configured", "bool", function(name, val) 2806 | request_tick() 2807 | end) 2808 | mp.observe_property("playback-time", "number", function(name, val) 2809 | request_tick() 2810 | end) 2811 | mp.observe_property("osd-dimensions", "native", function(name, val) 2812 | -- (we could use the value instead of re-querying it all the time, but then 2813 | -- we might have to worry about property update ordering) 2814 | request_init_resize() 2815 | end) 2816 | 2817 | -- mouse show/hide bindings 2818 | mp.set_key_bindings({ 2819 | {"mouse_move", function(e) process_event("mouse_move", nil) end}, 2820 | {"mouse_leave", mouse_leave}, 2821 | }, "showhide", "force") 2822 | mp.set_key_bindings({ 2823 | {"mouse_move", function(e) process_event("mouse_move", nil) end}, 2824 | {"mouse_leave", mouse_leave}, 2825 | }, "showhide_wc", "force") 2826 | do_enable_keybindings() 2827 | 2828 | --mouse input bindings 2829 | mp.set_key_bindings({ 2830 | {"mbtn_left", function(e) process_event("mbtn_left", "up") end, 2831 | function(e) process_event("mbtn_left", "down") end}, 2832 | {"shift+mbtn_left", function(e) process_event("shift+mbtn_left", "up") end, 2833 | function(e) process_event("shift+mbtn_left", "down") end}, 2834 | {"mbtn_right", function(e) process_event("mbtn_right", "up") end, 2835 | function(e) process_event("mbtn_right", "down") end}, 2836 | -- alias to shift_mbtn_left for single-handed mouse use 2837 | {"mbtn_mid", function(e) process_event("shift+mbtn_left", "up") end, 2838 | function(e) process_event("shift+mbtn_left", "down") end}, 2839 | {"wheel_up", function(e) process_event("wheel_up", "press") end}, 2840 | {"wheel_down", function(e) process_event("wheel_down", "press") end}, 2841 | {"mbtn_left_dbl", "ignore"}, 2842 | {"shift+mbtn_left_dbl", "ignore"}, 2843 | {"mbtn_right_dbl", "ignore"}, 2844 | }, "input", "force") 2845 | mp.enable_key_bindings("input") 2846 | 2847 | mp.set_key_bindings({ 2848 | {"mbtn_left", function(e) process_event("mbtn_left", "up") end, 2849 | function(e) process_event("mbtn_left", "down") end}, 2850 | }, "window-controls", "force") 2851 | mp.enable_key_bindings("window-controls") 2852 | 2853 | function get_hidetimeout() 2854 | if user_opts.visibility == "always" then 2855 | return -1 -- disable autohide 2856 | end 2857 | return user_opts.hidetimeout 2858 | end 2859 | 2860 | function always_on(val) 2861 | if state.enabled then 2862 | if val then 2863 | show_osc() 2864 | else 2865 | hide_osc() 2866 | end 2867 | end 2868 | end 2869 | 2870 | -- mode can be auto/always/never/cycle 2871 | -- the modes only affect internal variables and not stored on its own. 2872 | function visibility_mode(mode, no_osd) 2873 | if mode == "cycle" then 2874 | if not state.enabled then 2875 | mode = "auto" 2876 | elseif user_opts.visibility ~= "always" then 2877 | mode = "always" 2878 | else 2879 | mode = "never" 2880 | end 2881 | end 2882 | 2883 | if mode == "auto" then 2884 | always_on(false) 2885 | enable_osc(true) 2886 | elseif mode == "always" then 2887 | enable_osc(true) 2888 | always_on(true) 2889 | elseif mode == "never" then 2890 | enable_osc(false) 2891 | else 2892 | msg.warn("Ignoring unknown visibility mode '" .. mode .. "'") 2893 | return 2894 | end 2895 | 2896 | user_opts.visibility = mode 2897 | if utils.shared_script_property_set then 2898 | utils.shared_script_property_set("osc-visibility", mode) 2899 | end 2900 | mp.set_property_native("user-data/osc/visibility", mode) 2901 | 2902 | if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then 2903 | mp.osd_message("OSC visibility: " .. mode) 2904 | end 2905 | 2906 | -- Reset the input state on a mode change. The input state will be 2907 | -- recalculated on the next render cycle, except in 'never' mode where it 2908 | -- will just stay disabled. 2909 | mp.disable_key_bindings("input") 2910 | mp.disable_key_bindings("window-controls") 2911 | state.input_enabled = false 2912 | 2913 | update_margins() 2914 | request_tick() 2915 | end 2916 | 2917 | function idlescreen_visibility(mode, no_osd) 2918 | if mode == "cycle" then 2919 | if user_opts.idlescreen then 2920 | mode = "no" 2921 | else 2922 | mode = "yes" 2923 | end 2924 | end 2925 | 2926 | if mode == "yes" then 2927 | user_opts.idlescreen = true 2928 | else 2929 | user_opts.idlescreen = false 2930 | end 2931 | 2932 | if utils.shared_script_property_set then 2933 | utils.shared_script_property_set("osc-idlescreen", mode) 2934 | end 2935 | mp.set_property_native("user-data/osc/idlescreen", user_opts.idlescreen) 2936 | 2937 | if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then 2938 | mp.osd_message("OSC logo visibility: " .. tostring(mode)) 2939 | end 2940 | 2941 | request_tick() 2942 | end 2943 | 2944 | visibility_mode(user_opts.visibility, true) 2945 | mp.register_script_message("osc-visibility", visibility_mode) 2946 | mp.add_key_binding(nil, "visibility", function() visibility_mode("cycle") end) 2947 | 2948 | mp.register_script_message("osc-idlescreen", idlescreen_visibility) 2949 | 2950 | set_virt_mouse_area(0, 0, 0, 0, "input") 2951 | set_virt_mouse_area(0, 0, 0, 0, "window-controls") 2952 | -------------------------------------------------------------------------------- /quality-menu-preview-osc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christoph-heinrich/mpv-quality-menu/6e4dc5ee8d41b422239ae504c52647a1478675b7/quality-menu-preview-osc.jpg -------------------------------------------------------------------------------- /quality-menu.conf: -------------------------------------------------------------------------------- 1 | # KEY BINDINGS 2 | 3 | # move the menu cursor up 4 | up_binding=UP WHEEL_UP 5 | # move the menu cursor down 6 | down_binding=DOWN WHEEL_DOWN 7 | # select menu entry 8 | select_binding=ENTER MBTN_LEFT 9 | # close menu 10 | close_menu_binding=ESC MBTN_RIGHT 11 | 12 | # formatting / cursors 13 | selected_and_active=▶ - 14 | selected_and_inactive=● - 15 | unselected_and_active=▷ - 16 | unselected_and_inactive=○ - 17 | 18 | # font size scales by window, if false requires larger font and padding sizes 19 | scale_playlist_by_window=yes 20 | 21 | # playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua 22 | # example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1 23 | # read https://aegi.vmoe.info/docs/3.0/ASS_Tags/ for reference of tags 24 | # undeclared tags will use default osd settings 25 | # these styles will be used for the whole playlist. More specific styling will need to be hacked in 26 | # 27 | # (a monospaced font is recommended but not required) 28 | style_ass_tags={\\fnmonospace\\fs25\\bord1} 29 | 30 | # Shift drawing coordinates. Required for mpv.net compatiblity 31 | shift_x=0 32 | shift_y=0 33 | 34 | # paddings for top left corner 35 | text_padding_x=5 36 | text_padding_y=10 37 | 38 | # Screen dim when menu is open 39 | curtain_opacity=0.7 40 | 41 | # how many seconds until the quality menu times out 42 | # setting this to 0 deactivates the timeout 43 | menu_timeout=6 44 | 45 | # use youtube-dl to fetch a list of available formats (overrides quality_strings) 46 | fetch_formats=yes 47 | 48 | # list of ytdl-format strings to choose from 49 | quality_strings_video=[ {"4320p" : "bestvideo[height<=?4320p]"}, {"2160p" : "bestvideo[height<=?2160]"}, {"1440p" : "bestvideo[height<=?1440]"}, {"1080p" : "bestvideo[height<=?1080]"}, {"720p" : "bestvideo[height<=?720]"}, {"480p" : "bestvideo[height<=?480]"}, {"360p" : "bestvideo[height<=?360]"}, {"240p" : "bestvideo[height<=?240]"}, {"144p" : "bestvideo[height<=?144]"} ] 50 | quality_strings_audio=[ {"default" : "bestaudio"} ] 51 | 52 | # show the video format menu after opening an url 53 | start_with_menu=no 54 | 55 | # include unknown formats in the list 56 | # Unfortunately choosing which formats are video or audio is not always perfect. 57 | # Set to true to make sure you don't miss any formats, but then the list 58 | # might also include formats that aren't actually video or audio. 59 | # Formats that are known to not be video or audio are still filtered out. 60 | include_unknown=no 61 | 62 | # hide columns that are identical for all formats 63 | hide_identical_columns=yes 64 | 65 | # which columns are shown in which order 66 | # comma separated list, prefix column with "-" to align left 67 | # 68 | # for the uosc integration it is possible to split the text up into a title and a hint 69 | # this is done by separating two columns with a "|" instead of a comma 70 | # column order in the hint is reversed 71 | # 72 | # columns that might be useful are: 73 | # resolution, width, height, fps, dynamic_range, tbr, vbr, abr, asr, 74 | # filesize, filesize_approx, vcodec, acodec, ext, video_ext, audio_ext, 75 | # language, format, format_note, quality 76 | # 77 | # columns that are derived from the above, but with special treatment: 78 | # size, frame_rate, bitrate_total, bitrate_video, bitrate_audio, 79 | # codec_video, codec_audio, audio_sample_rate 80 | # 81 | # If those still aren't enough or you're just curious, run: 82 | # yt-dlp -j 83 | # This outputs unformatted JSON. 84 | # Format it and look under "formats" to see what's available. 85 | # 86 | # Not all videos have all columns available. 87 | # Be careful, misspelled columns simply won't be displayed, there is no error. 88 | columns_video=-resolution,frame_rate,dynamic_range|language,bitrate_total,size,-codec_video,-codec_audio 89 | columns_audio=audio_sample_rate,bitrate_total|size,language,-codec_audio 90 | 91 | # columns used for sorting, see "columns_video" for available columns 92 | # comma separated list, prefix column with "-" to reverse sorting order 93 | # Leaving this empty keeps the order from yt-dlp/youtube-dl. 94 | # Be careful, misspelled columns won't result in an error, 95 | # but they might influence the result. 96 | sort_video=height,fps,tbr,size,format_id 97 | sort_audio=asr,tbr,size,format_id 98 | -------------------------------------------------------------------------------- /quality-menu.lua: -------------------------------------------------------------------------------- 1 | -- quality-menu 4.2.0 - 2024-Oct-04 2 | -- https://github.com/christoph-heinrich/mpv-quality-menu 3 | -- 4 | -- Change the stream video and audio quality on the fly. 5 | -- 6 | -- Usage: 7 | -- add bindings to input.conf: 8 | -- F script-binding quality_menu/video_formats_toggle 9 | -- Alt+f script-binding quality_menu/audio_formats_toggle 10 | 11 | local mp = require 'mp' 12 | local utils = require 'mp.utils' 13 | local msg = require 'mp.msg' 14 | local assdraw = require 'mp.assdraw' 15 | local opt = require('mp.options') 16 | local script_name = mp.get_script_name() 17 | 18 | local opts = { 19 | --key bindings 20 | up_binding = 'UP WHEEL_UP', 21 | down_binding = 'DOWN WHEEL_DOWN', 22 | select_binding = 'ENTER MBTN_LEFT', 23 | close_menu_binding = 'ESC MBTN_RIGHT', 24 | 25 | --formatting / cursors 26 | selected_and_active = '▶ - ', 27 | selected_and_inactive = '● - ', 28 | unselected_and_active = '▷ - ', 29 | unselected_and_inactive = '○ - ', 30 | 31 | --font size scales by window, if false requires larger font and padding sizes 32 | scale_playlist_by_window = true, 33 | 34 | --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua 35 | --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1 36 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 37 | --undeclared tags will use default osd settings 38 | --these styles will be used for the whole playlist. More specific styling will need to be hacked in 39 | -- 40 | --(a monospaced font is recommended but not required) 41 | style_ass_tags = '{\\fnmonospace\\fs25\\bord1}', 42 | 43 | -- Shift drawing coordinates. Required for mpv.net compatiblity 44 | shift_x = 0, 45 | shift_y = 0, 46 | 47 | --paddings from window edge 48 | text_padding_x = 5, 49 | text_padding_y = 10, 50 | 51 | --Screen dim when menu is open 52 | curtain_opacity = 0.7, 53 | 54 | --how many seconds until the quality menu times out 55 | --setting this to 0 deactivates the timeout 56 | menu_timeout = 6, 57 | 58 | --use youtube-dl to fetch a list of available formats (overrides quality_strings) 59 | fetch_formats = true, 60 | 61 | --list of ytdl-format strings to choose from 62 | quality_strings_video = [[ 63 | [ 64 | {"4320p" : "bestvideo[height<=?4320p]"}, 65 | {"2160p" : "bestvideo[height<=?2160]"}, 66 | {"1440p" : "bestvideo[height<=?1440]"}, 67 | {"1080p" : "bestvideo[height<=?1080]"}, 68 | {"720p" : "bestvideo[height<=?720]"}, 69 | {"480p" : "bestvideo[height<=?480]"}, 70 | {"360p" : "bestvideo[height<=?360]"}, 71 | {"240p" : "bestvideo[height<=?240]"}, 72 | {"144p" : "bestvideo[height<=?144]"} 73 | ] 74 | ]], 75 | quality_strings_audio = [[ 76 | [ 77 | {"default" : "bestaudio/best"} 78 | ] 79 | ]], 80 | 81 | --show the video format menu after opening an url 82 | start_with_menu = false, 83 | 84 | --include unknown formats in the list 85 | --Unfortunately choosing which formats are video or audio is not always perfect. 86 | --Set to true to make sure you don't miss any formats, but then the list 87 | --might also include formats that aren't actually video or audio. 88 | --Formats that are known to not be video or audio are still filtered out. 89 | include_unknown = false, 90 | 91 | --hide columns that are identical for all formats 92 | hide_identical_columns = true, 93 | 94 | --which columns are shown in which order 95 | --comma separated list, prefix column with "-" to align left 96 | -- 97 | --for the uosc integration it is possible to split the text up into a title and a hint 98 | --this is done by separating two columns with a "|" instead of a comma 99 | --column order in the hint is reversed 100 | -- 101 | --columns that might be useful are: 102 | --resolution, width, height, fps, dynamic_range, tbr, vbr, abr, asr, 103 | --filesize, filesize_approx, vcodec, acodec, ext, video_ext, audio_ext, 104 | --language, format, format_note, quality 105 | -- 106 | --columns that are derived from the above, but with special treatment: 107 | --size, frame_rate, bitrate_total, bitrate_video, bitrate_audio, 108 | --codec_video, codec_audio, audio_sample_rate 109 | -- 110 | --If those still aren't enough or you're just curious, run: 111 | --yt-dlp -j 112 | --This outputs unformatted JSON. 113 | --Format it and look under "formats" to see what's available. 114 | -- 115 | --Not all videos have all columns available. 116 | --Be careful, misspelled columns simply won't be displayed, there is no error. 117 | columns_video = '-resolution,frame_rate,dynamic_range|language,bitrate_total,size,-codec_video,-codec_audio', 118 | columns_audio = 'audio_sample_rate,bitrate_total|size,language,-codec_audio', 119 | 120 | --columns used for sorting, see "columns_video" for available columns 121 | --comma separated list, prefix column with "-" to reverse sorting order 122 | --Leaving this empty keeps the order from yt-dlp/youtube-dl. 123 | --Be careful, misspelled columns won't result in an error, 124 | --but they might influence the result. 125 | sort_video = 'height,fps,tbr,size,format_id', 126 | sort_audio = 'asr,tbr,size,format_id', 127 | } 128 | opt.read_options(opts, 'quality-menu') 129 | 130 | ---@alias Format { properties: {[string]: string}, id: string, label?: string, title?: string, hint?: string } 131 | -- *_active_id == nil means unknown, *_active_id == '' means disabled 132 | ---@alias Data { video_formats: Format[], audio_formats: Format[], video_active_id?: string, audio_active_id?: string } 133 | ---@alias UIState { type: string, type_capitalized: string, name: string , to_other_type: UIState, to_fetching: UIState, to_menu: UIState, is_video: boolean } 134 | 135 | do 136 | ---@param option_string string 137 | ---@param option_name string 138 | ---@return Format[] 139 | local function parse_predefined(option_string, option_name) 140 | ---@type {[string]: string}[] 141 | local json, error = utils.parse_json(option_string) 142 | if error then 143 | msg.error('Error while parsing JSON of option ' .. option_name .. ': ' .. error) 144 | return {} 145 | end 146 | ---@type Format[] 147 | local formats = {} 148 | for i, format in ipairs(json) do 149 | local label, format_string = next(format) 150 | formats[i] = { 151 | label = label, 152 | title = label, 153 | id = format_string, 154 | } 155 | end 156 | return formats 157 | end 158 | 159 | ---@type Data 160 | opts.predefined_data = { 161 | video_formats = parse_predefined(opts.quality_strings_video, 'quality_strings_video'), 162 | audio_formats = parse_predefined(opts.quality_strings_audio, 'quality_strings_audio'), 163 | video_active_id = nil, 164 | audio_active_id = nil, 165 | } 166 | end 167 | 168 | opts.font_size = tonumber(opts.style_ass_tags:match('\\fs(%d+%.?%d*)')) or mp.get_property_number('osd-font-size') or 25 169 | opts.curtain_opacity = math.max(math.min(opts.curtain_opacity, 1), 0) 170 | 171 | ---@param input string 172 | ---@param separator string 173 | ---@return string[] 174 | local function string_split(input, separator) 175 | if separator == nil then 176 | separator = '%s' 177 | end 178 | local t = {} 179 | for str in string.gmatch(input, '([^' .. separator .. ']+)') do 180 | table.insert(t, str) 181 | end 182 | return t 183 | end 184 | 185 | ---@param strings string[] 186 | ---@return string[], boolean[] 187 | local function strip_minus(strings) 188 | local stripped_list = {} 189 | local had_minus = {} 190 | for i, val in ipairs(strings) do 191 | if string.sub(val, 1, 1) == '-' then 192 | val = string.sub(val, 2) 193 | had_minus[val] = true 194 | end 195 | stripped_list[i] = val 196 | end 197 | return stripped_list, had_minus 198 | end 199 | 200 | do 201 | ---@param column_definition string 202 | ---@return { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } 203 | local function parse_columns(column_definition) 204 | local columns, columns_align_left = strip_minus(string_split(column_definition, '|,')) 205 | local title_hint = string_split(column_definition, '|') 206 | local title, title_align_left = strip_minus(string_split(title_hint[1], ',')) 207 | 208 | local hint = nil 209 | if title_hint[2] then 210 | hint = strip_minus(string_split(title_hint[2], ',')) 211 | -- reverse column order 212 | local n = #hint 213 | for i = 1, n / 2 do 214 | hint[i], hint[n - i + 1] = hint[n - i + 1], hint[i] 215 | end 216 | end 217 | return { 218 | all = columns, all_align_left = columns_align_left, 219 | title = title, title_align_left = title_align_left, 220 | hint = hint 221 | } 222 | end 223 | 224 | ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } 225 | ---@diagnostic disable-next-line: param-type-mismatch 226 | opts.columns_video = parse_columns(opts.columns_video) 227 | ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } 228 | ---@diagnostic disable-next-line: param-type-mismatch 229 | opts.columns_audio = parse_columns(opts.columns_audio) 230 | end 231 | 232 | -- special thanks to reload.lua (https://github.com/4e6/mpv-reload/) 233 | local function reload_resume() 234 | local reload_duration = mp.get_property_native('duration') 235 | local time_pos = mp.get_property('time-pos') 236 | 237 | mp.command('playlist-play-index current') 238 | 239 | -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero 240 | -- duration property. When reloading VOD, to keep the current time position 241 | -- we should provide offset from the start. Stream doesn't have fixed start. 242 | -- Decent choice would be to reload stream from it's current 'live' position. 243 | -- That's the reason we don't pass the offset when reloading streams. 244 | if reload_duration and reload_duration > 0 and time_pos then 245 | local function seeker() 246 | mp.commandv('seek', time_pos, 'absolute+exact') 247 | mp.unregister_event(seeker) 248 | end 249 | 250 | mp.register_event('file-loaded', seeker) 251 | end 252 | end 253 | 254 | ---@type { video_menu: UIState, audio_menu: UIState, video_fetching: UIState, audio_fetching: UIState } 255 | local states = { 256 | video_menu = { type = 'video', type_capitalized = 'Video', name = 'video_menu', is_video = true }, 257 | audio_menu = { type = 'audio', type_capitalized = 'Audio', name = 'audio_menu', is_video = false }, 258 | video_fetching = { type = 'video', type_capitalized = 'Video', name = 'video_fetching', is_video = true }, 259 | audio_fetching = { type = 'audio', type_capitalized = 'Audio', name = 'audio_fetching', is_video = false }, 260 | } 261 | states.video_menu.to_fetching = states.video_fetching 262 | states.video_menu.to_menu = states.video_menu 263 | states.video_menu.to_other_type = states.audio_menu 264 | states.audio_menu.to_fetching = states.audio_fetching 265 | states.audio_menu.to_menu = states.audio_menu 266 | states.audio_menu.to_other_type = states.video_menu 267 | states.video_fetching.to_fetching = states.video_fetching 268 | states.video_fetching.to_menu = states.video_menu 269 | states.video_fetching.to_other_type = states.audio_fetching 270 | states.audio_fetching.to_fetching = states.audio_fetching 271 | states.audio_fetching.to_menu = states.audio_menu 272 | states.audio_fetching.to_other_type = states.video_fetching 273 | 274 | ---@type UIState | nil 275 | local open_menu_state = nil 276 | ---@type string | nil 277 | local current_url = nil 278 | ---@type function | nil 279 | local destructor = nil 280 | 281 | local menu_open 282 | local menu_close 283 | local video_formats_toggle 284 | local audio_formats_toggle 285 | 286 | local osd = mp.create_osd_overlay('ass-events') 287 | 288 | local function hide_osd() 289 | -- workaround mpv bug, setting to hidden does not cause a redraw 290 | -- https://github.com/mpv-player/mpv/issues/10227 291 | osd.data = '' 292 | osd:update() 293 | osd.hidden = true 294 | osd:update() 295 | end 296 | 297 | local osd_timer = mp.add_timeout(1, function() menu_close() end) 298 | osd_timer:kill() 299 | 300 | ---@param message string 301 | ---@param time number 302 | local function osd_message(message, time) 303 | osd.res_x = 1280 304 | osd.res_y = 720 305 | osd.hidden = false 306 | osd.data = message 307 | osd:update() 308 | osd_timer.timeout = time 309 | osd_timer:kill() 310 | osd_timer:resume() 311 | end 312 | 313 | ---@alias FormatRaw {format_id: string, vcodec?: string, acodec?: string, filesize: integer?, filesize_approx?: integer, fps?: number, tbr?: number, vbr?: number, abr?: number, asr?: number} 314 | 315 | ---@param json {formats: FormatRaw[], requested_formats: FormatRaw, requested_downloads: FormatRaw} 316 | ---@return Data 317 | local function process_json(json) 318 | ---@param format FormatRaw 319 | ---@return boolean 320 | local function is_video(format) 321 | -- 'none' means it is not a video 322 | -- nil means it is unknown 323 | return (opts.include_unknown or format.vcodec) and format.vcodec ~= 'none' or false 324 | end 325 | 326 | ---@param format FormatRaw 327 | ---@return boolean 328 | local function is_audio(format) 329 | return (opts.include_unknown or format.acodec) and format.acodec ~= 'none' or false 330 | end 331 | 332 | local requested_video = nil 333 | local requested_audio = nil 334 | local requested_formats = json.requested_formats or json.requested_downloads or {} 335 | for _, format in ipairs(requested_formats) do 336 | if is_video(format) then 337 | requested_video = format.format_id 338 | elseif is_audio(format) then 339 | requested_audio = format.format_id 340 | end 341 | end 342 | 343 | local video_formats = {} 344 | local audio_formats = {} 345 | local all_formats = {} 346 | for i = #json.formats, 1, -1 do 347 | local format = json.formats[i] 348 | if is_video(format) then 349 | video_formats[#video_formats + 1] = format 350 | all_formats[#all_formats + 1] = format 351 | elseif is_audio(format) then 352 | audio_formats[#audio_formats + 1] = format 353 | all_formats[#all_formats + 1] = format 354 | end 355 | end 356 | 357 | ---@param format FormatRaw 358 | local function populate_special_fields(format) 359 | format.size = format.filesize or format.filesize_approx 360 | format.frame_rate = format.fps 361 | format.bitrate_total = format.tbr 362 | format.bitrate_video = format.vbr 363 | format.bitrate_audio = format.abr 364 | format.codec_video = format.vcodec 365 | format.codec_audio = format.acodec 366 | format.audio_sample_rate = format.asr 367 | end 368 | 369 | for _, format in ipairs(all_formats) do 370 | populate_special_fields(format) 371 | end 372 | 373 | local sort_video, reverse_video = strip_minus(string_split(opts.sort_video, ',')) 374 | local sort_audio, reverse_audio = strip_minus(string_split(opts.sort_audio, ',')) 375 | 376 | ---@param properties string[] 377 | ---@param reverse {[string]: boolean} 378 | ---@return fun(a: FormatRaw, b: FormatRaw): boolean 379 | local function comp(properties, reverse) 380 | return function(a, b) 381 | for _, prop in ipairs(properties) do 382 | local a_val = a[prop] 383 | local b_val = b[prop] 384 | if a_val and b_val and type(a_val) ~= 'table' and a_val ~= b_val then 385 | if reverse[prop] then 386 | return a_val < b_val 387 | else 388 | return a_val > b_val 389 | end 390 | end 391 | end 392 | return false 393 | end 394 | end 395 | 396 | if #sort_video > 0 then 397 | table.sort(video_formats, comp(sort_video, reverse_video)) 398 | end 399 | if #sort_audio > 0 then 400 | table.sort(audio_formats, comp(sort_audio, reverse_audio)) 401 | end 402 | 403 | ---@param size integer 404 | ---@return string 405 | local function scale_filesize(size) 406 | if size == nil then 407 | return '' 408 | end 409 | 410 | local counter = 0 411 | while size > 1024 do 412 | size = size / 1024 413 | counter = counter + 1 414 | end 415 | 416 | if counter >= 3 then return string.format('%.1fGiB', size) 417 | elseif counter >= 2 then return string.format('%.1fMiB', size) 418 | elseif counter >= 1 then return string.format('%.1fKiB', size) 419 | else return string.format('%.1fB ', size) 420 | end 421 | end 422 | 423 | ---@param bitrate integer 424 | ---@return string 425 | local function scale_bitrate(bitrate) 426 | if bitrate == nil then 427 | return '' 428 | end 429 | 430 | local counter = 0 431 | while bitrate > 1000 do 432 | bitrate = bitrate / 1000 433 | counter = counter + 1 434 | end 435 | 436 | if counter >= 2 then return string.format('%.1fGbps', bitrate) 437 | elseif counter >= 1 then return string.format('%.1fMbps', bitrate) 438 | else return string.format('%.1fKbps', bitrate) 439 | end 440 | end 441 | 442 | ---@param format FormatRaw 443 | local function format_special_fields(format) 444 | local size_prefix = not format.filesize and format.filesize_approx and '~' or '' 445 | ---@diagnostic disable-next-line: param-type-mismatch 446 | format.size = (size_prefix) .. scale_filesize(format.size) 447 | format.frame_rate = format.fps and format.fps .. 'fps' or '' 448 | format.bitrate_total = scale_bitrate(format.tbr) 449 | format.bitrate_video = scale_bitrate(format.vbr) 450 | format.bitrate_audio = scale_bitrate(format.abr) 451 | format.codec_video = format.vcodec == nil and 'unknown' or format.vcodec == 'none' and '' or format.vcodec 452 | format.codec_audio = format.acodec == nil and 'unknown' or format.acodec == 'none' and '' or format.acodec 453 | format.audio_sample_rate = format.asr and tostring(format.asr) .. 'Hz' or '' 454 | end 455 | 456 | for _, format in ipairs(all_formats) do 457 | format_special_fields(format) 458 | end 459 | 460 | ---@param raw_formats { [string]: any } 461 | ---@param properties string[] 462 | ---@return Format[] 463 | local function convert_to_format(raw_formats, properties) 464 | ---@type Format[] 465 | local formats = {} 466 | for i, format in ipairs(raw_formats) do 467 | local props = {} 468 | for _, prop in ipairs(properties) do 469 | props[prop] = tostring(format[prop] or '') 470 | end 471 | formats[i] = { properties = props, id = format.format_id } 472 | end 473 | return formats 474 | end 475 | 476 | return { 477 | video_formats = convert_to_format(video_formats, opts.columns_video.all), 478 | audio_formats = convert_to_format(audio_formats, opts.columns_audio.all), 479 | video_active_id = requested_video, 480 | audio_active_id = requested_audio, 481 | } 482 | end 483 | 484 | ---@return string | nil 485 | local function get_url() 486 | local path = mp.get_property('path') 487 | if not path then return nil end 488 | path = path:gsub('ytdl://', '') -- Strip possible ytdl:// prefix. 489 | 490 | ---@param str string 491 | ---@return boolean 492 | local function is_url(str) 493 | -- adapted the regex from 494 | -- https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url 495 | return nil ~= 496 | str:match( 497 | '^[%w]-://[-a-zA-Z0-9@:%._\\+~#=]+%.' .. 498 | '[a-zA-Z0-9()][a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?' .. 499 | '[-a-zA-Z0-9()@:%_\\+.~#?&/=]*') 500 | end 501 | 502 | return is_url(path) and path or nil 503 | end 504 | 505 | local uosc_available = false 506 | ---@type { [string]: Data } 507 | local url_data = {} 508 | 509 | local function uosc_set_format_counts() 510 | if not uosc_available then return end 511 | 512 | local data = url_data[current_url] 513 | if data then 514 | mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.video_formats) 515 | mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.audio_formats) 516 | else 517 | mp.commandv('script-message-to', 'uosc', 'set', 'vformats', 0) 518 | mp.commandv('script-message-to', 'uosc', 'set', 'aformats', 0) 519 | end 520 | end 521 | 522 | ---@param json string 523 | ---@return Data | nil 524 | local function process_json_string(json) 525 | local json_table, err = utils.parse_json(json) 526 | 527 | if (json_table == nil) then 528 | osd_message('fetching formats failed...', 2) 529 | if err == nil then err = 'unexpected error occurred' end 530 | msg.error('failed to parse JSON data: ' .. err) 531 | return 532 | end 533 | 534 | if json_table.formats == nil then 535 | return 536 | end 537 | 538 | return process_json(json_table) 539 | end 540 | 541 | ---Unknown format falls back on highest ranked format if possible 542 | ---@param id string | nil 543 | ---@param formats Format[] 544 | ---@return string 545 | local function sanitize_format_id(id, formats) 546 | return id or (formats[1] or {}).id or '' 547 | end 548 | 549 | ---@param video_id string 550 | ---@param audio_id string 551 | ---@return string 552 | local function format_string(video_id, audio_id) 553 | if #video_id > 0 and #audio_id > 0 then 554 | return video_id .. '+' .. audio_id 555 | elseif #video_id > 0 then 556 | return video_id 557 | elseif #audio_id > 0 then 558 | return audio_id 559 | else 560 | return '' 561 | end 562 | end 563 | 564 | ---@param url string 565 | ---@param video_format string 566 | ---@param audio_format string 567 | local function set_format(url, video_format, audio_format) 568 | if (url_data[url].video_active_id ~= video_format or url_data[url].audio_active_id ~= audio_format) then 569 | url_data[url].video_active_id = video_format 570 | url_data[url].audio_active_id = audio_format 571 | if url == mp.get_property('path') then reload_resume() end 572 | end 573 | end 574 | 575 | ---@param formats Format[] 576 | ---@param active_format string | nil 577 | ---@param menu_type UIState 578 | local function text_menu_open(formats, active_format, menu_type) 579 | local active = 0 580 | local selected = 1 581 | --set the cursor to the current format 582 | for i, format in ipairs(formats) do 583 | if format.id == active_format then 584 | active = i 585 | selected = active 586 | break 587 | end 588 | end 589 | if active_format == '' then 590 | active = #formats + 1 591 | selected = active 592 | end 593 | 594 | ---@param i integer 595 | ---@return string 596 | local function choose_prefix(i) 597 | if i == selected and i == active then return opts.selected_and_active 598 | elseif i == selected then return opts.selected_and_inactive end 599 | 600 | if i ~= selected and i == active then return opts.unselected_and_active 601 | elseif i ~= selected then return opts.unselected_and_inactive end 602 | return '> ' --shouldn't get here. 603 | end 604 | 605 | local width, height 606 | local margin_top, margin_bottom = 0, 0 607 | local num_options = #formats > 0 and #formats + 2 or 1 608 | 609 | ---@return integer 610 | local function get_scrolled_lines() 611 | local output_height = height - opts.text_padding_y * 2 - margin_top * height - margin_bottom * height 612 | local screen_lines = math.max(math.floor(output_height / opts.font_size), 1) 613 | local max_scroll = math.max(num_options - screen_lines, 0) 614 | return math.min(math.max(selected - math.ceil(screen_lines / 2), 0), max_scroll) 615 | end 616 | 617 | local function draw_menu() 618 | local ass = assdraw.ass_new() 619 | 620 | if opts.curtain_opacity > 0 then 621 | local alpha = 255 - math.ceil(255 * opts.curtain_opacity) 622 | ass.text = string.format('{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}', alpha) 623 | ass:draw_start() 624 | ass:rect_cw(0, 0, width, height) 625 | ass:draw_stop() 626 | ass:new_event() 627 | end 628 | 629 | local scrolled_lines = get_scrolled_lines() 630 | local pos_y = opts.shift_y + margin_top * height + opts.text_padding_y - scrolled_lines * opts.font_size 631 | ass:pos(opts.shift_x + opts.text_padding_x, pos_y) 632 | local clip_top = math.floor(margin_top * height + 0.5) 633 | local clip_bottom = math.floor((1 - margin_bottom) * height + 0.5) 634 | local clipping_coordinates = '0,' .. clip_top .. ',' .. width .. ',' .. clip_bottom 635 | ass:append('{\\rDefault\\an7\\q2\\clip(' .. clipping_coordinates .. ')}' .. opts.style_ass_tags) 636 | 637 | if #formats > 0 then 638 | for i, format in ipairs(formats) do 639 | ass:append(choose_prefix(i) .. format.label .. '\\N') 640 | end 641 | ass:append(choose_prefix(#formats + 1) .. 'Disabled\\N') 642 | ass:append(choose_prefix(#formats + 2) .. menu_type.to_other_type.type_capitalized .. ' menu') 643 | else 644 | ass:append('no formats found\\N') 645 | ass:append(opts.selected_and_inactive .. menu_type.to_other_type.type_capitalized .. ' menu') 646 | end 647 | 648 | osd.data = ass.text 649 | osd:update() 650 | end 651 | 652 | local function update_dimensions() 653 | local _, h, aspect = mp.get_osd_size() 654 | if opts.scale_playlist_by_window then h = 720 end 655 | height = h 656 | width = height * aspect 657 | osd.res_y = height 658 | osd.res_x = width 659 | draw_menu() 660 | end 661 | 662 | local update_margins = function(_, val) 663 | if not val then 664 | val = mp.get_property_native('user-data/osc/margins') 665 | end 666 | if val then 667 | margin_top = val.t 668 | margin_bottom = val.b 669 | else 670 | margin_top = 0 671 | margin_bottom = 0 672 | end 673 | draw_menu() 674 | end 675 | mp.observe_property('user-data/osc/margins', 'native', update_margins) 676 | 677 | update_dimensions() 678 | update_margins() 679 | mp.observe_property('osd-dimensions', 'native', update_dimensions) 680 | 681 | ---@param amount integer 682 | local function selected_move(amount) 683 | selected = selected + amount 684 | if selected < 1 then selected = num_options 685 | elseif selected > num_options then selected = 1 end 686 | if osd_timer then 687 | osd_timer:kill() 688 | osd_timer:resume() 689 | end 690 | draw_menu() 691 | end 692 | 693 | ---@param keys string | nil 694 | ---@param name string 695 | ---@param func function 696 | ---@param opts table | nil 697 | local function bind_keys(keys, name, func, opts) 698 | if not keys then 699 | mp.add_forced_key_binding(keys, name, func, opts) 700 | return 701 | end 702 | local i = 1 703 | for key in keys:gmatch('[^%s]+') do 704 | local prefix = i == 1 and '' or i 705 | mp.add_forced_key_binding(key, name .. prefix, func, opts) 706 | i = i + 1 707 | end 708 | end 709 | 710 | ---@param keys string | nil 711 | ---@param name string 712 | local function unbind_keys(keys, name) 713 | if not keys then 714 | mp.remove_key_binding(name) 715 | return 716 | end 717 | local i = 1 718 | for key in keys:gmatch('[^%s]+') do 719 | local prefix = i == 1 and '' or i 720 | mp.remove_key_binding(name .. prefix) 721 | i = i + 1 722 | end 723 | end 724 | 725 | -- make sure observers are cleaned up 726 | if open_menu_state and open_menu_state == open_menu_state.to_menu and destructor then destructor() end 727 | destructor = function() 728 | unbind_keys(opts.up_binding, 'move_up') 729 | unbind_keys(opts.down_binding, 'move_down') 730 | unbind_keys(opts.select_binding, 'select') 731 | unbind_keys(opts.close_menu_binding, 'close') 732 | mp.unobserve_property(update_dimensions) 733 | mp.unobserve_property(update_margins) 734 | end 735 | 736 | osd_timer:kill() 737 | if opts.menu_timeout > 0 then 738 | osd_timer.timeout = opts.menu_timeout 739 | osd_timer:resume() 740 | end 741 | 742 | bind_keys(opts.up_binding, 'move_up', function() selected_move( -1) end, { repeatable = true }) 743 | bind_keys(opts.down_binding, 'move_down', function() selected_move(1) end, { repeatable = true }) 744 | bind_keys(opts.close_menu_binding, 'close', menu_close) 745 | bind_keys(opts.select_binding, 'select', function() 746 | if selected == num_options then 747 | mp.unobserve_property(update_dimensions) 748 | mp.unobserve_property(update_margins) 749 | if menu_type.is_video then audio_formats_toggle() 750 | else video_formats_toggle() end 751 | return 752 | end 753 | menu_close() 754 | if selected == active then return end 755 | if current_url == nil then return end 756 | 757 | local video_id, audio_id 758 | local id = formats[selected] and formats[selected].id or '' 759 | local data = url_data[current_url] 760 | if menu_type.is_video then 761 | video_id = id 762 | audio_id = sanitize_format_id(data.audio_active_id, data.audio_formats) 763 | else 764 | video_id = sanitize_format_id(data.video_active_id, data.video_formats) 765 | audio_id = id 766 | end 767 | set_format(current_url, video_id, audio_id) 768 | end) 769 | 770 | osd.hidden = false 771 | draw_menu() 772 | end 773 | 774 | ---@param menu table 775 | ---@param menu_type UIState 776 | local function uosc_show_menu(menu, menu_type) 777 | local json = utils.format_json(menu) 778 | -- always using update wouldn't work, because it doesn't support the on_close command 779 | -- therefore opening a different kind requires `open-menu` 780 | -- while updating the same kind requires `update-menu` 781 | if open_menu_state == menu_type then mp.commandv('script-message-to', 'uosc', 'update-menu', json) 782 | else mp.commandv('script-message-to', 'uosc', 'open-menu', json) end 783 | end 784 | 785 | ---@param formats Format[] 786 | ---@param active_format string | nil 787 | ---@param menu_type UIState 788 | local function uosc_menu_open(formats, active_format, menu_type) 789 | local menu = { 790 | title = menu_type.type_capitalized .. ' Formats', 791 | items = {}, 792 | type = 'quality-menu-' .. menu_type.name, 793 | keep_open = true, 794 | on_close = { 795 | 'script-message-to', 796 | script_name, 797 | 'uosc-menu-closed', 798 | menu_type.name, 799 | } 800 | } 801 | 802 | menu.items[#menu.items + 1] = { 803 | title = menu_type.to_other_type.type_capitalized, 804 | italic = true, 805 | bold = true, 806 | hint = 'open menu', 807 | value = { 808 | 'script-message-to', 809 | script_name, 810 | menu_type.to_other_type.type .. '_formats_toggle', 811 | }, 812 | } 813 | menu.items[#menu.items + 1] = { 814 | title = 'Disabled', 815 | italic = true, 816 | muted = true, 817 | hint = '—', 818 | active = active_format == '', 819 | value = { 820 | 'script-message-to', 821 | script_name, 822 | menu_type.type .. '-format-set', 823 | current_url, 824 | '', 825 | } 826 | } 827 | 828 | for _, format in ipairs(formats) do 829 | menu.items[#menu.items + 1] = { 830 | title = format.title, 831 | hint = format.hint, 832 | active = format.id == active_format, 833 | value = { 834 | 'script-message-to', 835 | script_name, 836 | menu_type.type .. '-format-set', 837 | current_url, 838 | format.id, 839 | } 840 | } 841 | end 842 | 843 | uosc_show_menu(menu, menu_type) 844 | destructor = function() 845 | mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type) 846 | end 847 | end 848 | 849 | ---Check if property is same for all formats 850 | ---@param formats Format[] 851 | ---@param properties string[] 852 | ---@return { [string]: boolean } 853 | local function identical_for_all(formats, properties) 854 | ---@param formats Format[] 855 | ---@param prop string 856 | ---@return boolean 857 | local function all_formats_same_value(formats, prop) 858 | local first_value = nil 859 | for _, format in ipairs(formats) do 860 | first_value = first_value or format.properties[prop] 861 | if format.properties[prop] ~= first_value then return false end 862 | end 863 | return true 864 | end 865 | 866 | local identical_props = {} 867 | for _, prop in ipairs(properties) do 868 | identical_props[prop] = all_formats_same_value(formats, prop) 869 | end 870 | return identical_props 871 | end 872 | 873 | ---@param formats Format[] 874 | ---@param columns string[] 875 | ---@param column_align_left boolean[] 876 | ---@return string[] 877 | local function format_table(formats, columns, column_align_left) 878 | local column_widths = {} 879 | for _, format in pairs(formats) do 880 | for col, prop in ipairs(columns) do 881 | local width = format.properties[prop]:len() 882 | if not column_widths[col] or column_widths[col] < width then 883 | column_widths[col] = width 884 | end 885 | end 886 | end 887 | 888 | local identical_columns = identical_for_all(formats, columns) 889 | 890 | local show_columns = {} 891 | for i, width in ipairs(column_widths) do 892 | local prop = columns[i] 893 | if width > 0 and not (opts.hide_identical_columns and identical_columns[prop]) then 894 | show_columns[#show_columns + 1] = { 895 | prop = prop, 896 | width = width, 897 | align_left = column_align_left[prop] 898 | } 899 | end 900 | end 901 | 902 | local spacing = 2 903 | ---@type string[] 904 | local rows = {} 905 | for i, format in ipairs(formats) do 906 | local row = {} 907 | for j, column in ipairs(show_columns) do 908 | -- lua errors out with width > 99 ("invalid conversion specification") 909 | local width = math.min(column.width * (column.align_left and -1 or 1), 99) 910 | row[j] = string.format('%' .. width .. 's', format.properties[column.prop] or '') 911 | end 912 | rows[i] = table.concat(row, string.format('%' .. spacing .. 's', '')):gsub('%s+$', '') 913 | end 914 | return rows 915 | end 916 | 917 | ---@param formats Format[] 918 | ---@param columns string[] 919 | ---@return string[] 920 | local function format_csv(formats, columns) 921 | local identical_props = identical_for_all(formats, columns) 922 | local hints = {} 923 | for i, format in ipairs(formats) do 924 | local row = {} 925 | for _, prop in ipairs(columns) do 926 | local val = format.properties[prop] 927 | if #val > 0 and not (opts.hide_identical_columns and identical_props[prop]) then 928 | row[#row + 1] = val 929 | end 930 | end 931 | hints[i] = table.concat(row, ', ') 932 | end 933 | return hints 934 | end 935 | 936 | ---@param formats Format[] 937 | ---@param menu_type UIState 938 | local function ensure_menu_data_filled(formats, menu_type) 939 | if uosc_available then 940 | if formats[1] and formats[1].title == nil then 941 | local columns = menu_type.is_video and opts.columns_video or opts.columns_audio 942 | local titles = format_table(formats, columns.title, columns.title_align_left) 943 | 944 | local hints = {} 945 | if columns.hint then 946 | hints = format_csv(formats, columns.hint) 947 | end 948 | 949 | for i, format in ipairs(formats) do 950 | format.title = titles[i] 951 | format.hint = hints[i] 952 | end 953 | end 954 | else 955 | if formats[1] and formats[1].label == nil then 956 | local columns = menu_type.is_video and opts.columns_video or opts.columns_audio 957 | local labels = format_table(formats, columns.all, columns.all_align_left) 958 | for i, format in ipairs(formats) do format.label = labels[i] end 959 | end 960 | end 961 | end 962 | 963 | ---@param menu_type UIState 964 | local function loading_message(menu_type) 965 | menu_type = menu_type.to_fetching 966 | if uosc_available then 967 | if open_menu_state and open_menu_state == menu_type then return end 968 | local menu = { 969 | title = menu_type.type_capitalized .. ' Formats', 970 | items = { { icon = 'spinner', selectable = false, value = 'ignore' } }, 971 | type = 'quality-menu-' .. menu_type.name, 972 | keep_open = true, 973 | on_close = { 974 | 'script-message-to', 975 | script_name, 976 | 'uosc-menu-closed', 977 | menu_type.name 978 | } 979 | } 980 | uosc_show_menu(menu, menu_type) 981 | destructor = function() 982 | mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type) 983 | end 984 | else 985 | osd_message('fetching available ' .. menu_type.type .. ' formats...', 60) 986 | end 987 | open_menu_state = menu_type 988 | end 989 | 990 | ---@param menu_type UIState 991 | function menu_open(menu_type) 992 | if not current_url then return end 993 | menu_type = menu_type.to_menu 994 | 995 | local data = url_data[current_url] 996 | if not data then 997 | if opts.fetch_formats then 998 | loading_message(menu_type) 999 | return 1000 | end 1001 | 1002 | -- shallow clone so that each url has it's own active format ids 1003 | data = {} 1004 | for k, v in pairs(opts.predefined_data) do 1005 | data[k] = v 1006 | end 1007 | url_data[current_url] = data 1008 | end 1009 | local formats = menu_type.is_video and data.video_formats or data.audio_formats 1010 | local active_format 1011 | if menu_type.is_video then active_format = data.video_active_id 1012 | else active_format = data.audio_active_id end 1013 | 1014 | msg.verbose('current ytdl-format: ' .. mp.get_property('ytdl-format', '')) 1015 | 1016 | ensure_menu_data_filled(formats, menu_type) 1017 | if uosc_available then uosc_menu_open(formats, active_format, menu_type) 1018 | else text_menu_open(formats, active_format, menu_type) end 1019 | open_menu_state = menu_type 1020 | end 1021 | 1022 | function menu_close() 1023 | if destructor then 1024 | destructor() 1025 | destructor = nil 1026 | end 1027 | if not osd.hidden then hide_osd() end 1028 | open_menu_state = nil 1029 | end 1030 | 1031 | ---@param menu_type UIState 1032 | local function toggle_menu(menu_type) 1033 | if open_menu_state and open_menu_state.type == menu_type.type then 1034 | menu_close() 1035 | return 1036 | end 1037 | 1038 | if current_url == nil then 1039 | if uosc_available then 1040 | if menu_type.is_video then 1041 | mp.commandv('script-binding', 'uosc/video') 1042 | else 1043 | mp.commandv('script-binding', 'uosc/audio') 1044 | end 1045 | end 1046 | return 1047 | end 1048 | 1049 | menu_open(menu_type) 1050 | end 1051 | 1052 | function video_formats_toggle() toggle_menu(states.video_menu) end 1053 | function audio_formats_toggle() toggle_menu(states.audio_menu) end 1054 | 1055 | -- keybind to launch menu 1056 | mp.add_key_binding(nil, 'video_formats_toggle', video_formats_toggle) 1057 | mp.add_key_binding(nil, 'audio_formats_toggle', audio_formats_toggle) 1058 | mp.add_key_binding(nil, 'reload', reload_resume) 1059 | 1060 | mp.register_event('start-file', function() 1061 | local new_url = get_url() 1062 | local url_changed = current_url ~= new_url 1063 | current_url = new_url 1064 | uosc_set_format_counts() 1065 | 1066 | -- new path isn't an url 1067 | if not new_url then return menu_close() end 1068 | 1069 | -- open or update menu 1070 | if opts.start_with_menu and url_changed or open_menu_state then 1071 | menu_open(open_menu_state or states.video_menu) 1072 | end 1073 | end) 1074 | 1075 | -- run before ytdl_hook, which uses a priority of 10 1076 | mp.add_hook('on_load', 9, function() 1077 | local path = mp.get_property('path') 1078 | local data = url_data[path] 1079 | if not (data and data.video_active_id and data.audio_active_id) then return end 1080 | local format = format_string(data.video_active_id, data.audio_active_id) 1081 | msg.verbose('setting ytdl-format: ' .. format) 1082 | mp.set_property('file-local-options/ytdl-format', format) 1083 | end) 1084 | 1085 | ---@param url string 1086 | ---@param format_id string 1087 | mp.register_script_message('video-format-set', function(url, format_id) 1088 | menu_close() 1089 | local data = url_data[url] 1090 | set_format(url, format_id, sanitize_format_id(data.audio_active_id, data.audio_formats)) 1091 | end) 1092 | 1093 | ---@param url string 1094 | ---@param format_id string 1095 | mp.register_script_message('audio-format-set', function(url, format_id) 1096 | menu_close() 1097 | local data = url_data[url] 1098 | set_format(url, sanitize_format_id(data.video_active_id, data.video_formats), format_id) 1099 | end) 1100 | 1101 | --- check if uosc is running 1102 | ---@param version string 1103 | mp.register_script_message('uosc-version', function(version) 1104 | ---Like the comperator for table.sort, this returns v1 < v2 1105 | ---Assumes two valid semver strings 1106 | ---@param v1 string 1107 | ---@param v2 string 1108 | ---@return boolean 1109 | local function semver_comp(v1, v2) 1110 | local v1_iterator = v1:gmatch('%d+') 1111 | local v2_iterator = v2:gmatch('%d+') 1112 | for v2_num_str in v2_iterator do 1113 | local v1_num_str = v1_iterator() 1114 | if not v1_num_str then return true end 1115 | local v1_num = tonumber(v1_num_str) 1116 | local v2_num = tonumber(v2_num_str) 1117 | if v1_num < v2_num then return true end 1118 | if v1_num > v2_num then return false end 1119 | end 1120 | return false 1121 | end 1122 | 1123 | local min_version = '4.6.0' 1124 | uosc_available = not semver_comp(version, min_version) 1125 | if not uosc_available then return end 1126 | uosc_set_format_counts() 1127 | mp.commandv( 1128 | 'script-message-to', 1129 | 'uosc', 1130 | 'overwrite-binding', 1131 | 'stream-quality', 1132 | 'script-binding ' .. script_name .. '/video_formats_toggle' 1133 | ) 1134 | ---@param name string 1135 | mp.register_script_message('uosc-menu-closed', function(name) 1136 | -- got closed from the uosc side 1137 | if open_menu_state and open_menu_state.name == name then 1138 | destructor = nil 1139 | menu_close() 1140 | end 1141 | end) 1142 | end) 1143 | mp.commandv('script-message-to', 'uosc', 'get-version', mp.get_script_name()) 1144 | 1145 | mp.observe_property('user-data/mpv/ytdl/json-subprocess-result', 'native', function(_, ytdl_result) 1146 | if not ytdl_result then 1147 | -- property gets deleted in on_after_end_file hook 1148 | return 1149 | end 1150 | 1151 | if not current_url then 1152 | osd_message('current_url is nil', 2) 1153 | msg.error('current_url is nil') 1154 | return 1155 | end 1156 | 1157 | local json = ytdl_result.stdout 1158 | 1159 | if ytdl_result.status ~= 0 or json == '' then 1160 | json = nil 1161 | osd_message('fetching formats failed...', 2) 1162 | elseif json then 1163 | ---@type Data | nil 1164 | local data = url_data[current_url] 1165 | if data == nil then 1166 | data = process_json_string(json) 1167 | url_data[current_url] = data 1168 | uosc_set_format_counts() 1169 | end 1170 | if not data then return end 1171 | if open_menu_state and open_menu_state == open_menu_state.to_fetching then 1172 | menu_open(open_menu_state) 1173 | end 1174 | end 1175 | 1176 | end) 1177 | --------------------------------------------------------------------------------