From 01ca98aa4be58e17c3230bde205ac7cc21f176d9 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Tue, 23 May 2023 15:31:17 +0200 Subject: [PATCH] mpv: Update gui interface --- multimedia/.config/mpv/fonts/uosc_icons.otf | Bin 0 -> 400360 bytes .../.config/mpv/fonts/uosc_textures.ttf | Bin 0 -> 38228 bytes multimedia/.config/mpv/input.conf | 6 +- multimedia/.config/mpv/scripts/battery.lua | 13 +- .../.config/mpv/scripts/copy_videotime.lua | 80 + multimedia/.config/mpv/scripts/gallery-dl.lua | 13 +- .../mpv/scripts/sponsorblock_minimal.lua | 6 +- multimedia/.config/mpv/scripts/thumbfast.lua | 975 ++++ multimedia/.config/mpv/scripts/uosc.lua | 4716 ++++------------- .../elements/BufferingIndicator.lua | 37 + .../scripts/uosc_shared/elements/Button.lua | 90 + .../scripts/uosc_shared/elements/Controls.lua | 329 ++ .../scripts/uosc_shared/elements/Curtain.lua | 35 + .../uosc_shared/elements/CycleButton.lua | 64 + .../scripts/uosc_shared/elements/Element.lua | 154 + .../scripts/uosc_shared/elements/Elements.lua | 125 + .../mpv/scripts/uosc_shared/elements/Menu.lua | 854 +++ .../uosc_shared/elements/PauseIndicator.lua | 80 + .../scripts/uosc_shared/elements/Speed.lua | 192 + .../scripts/uosc_shared/elements/Timeline.lua | 430 ++ .../scripts/uosc_shared/elements/TopBar.lua | 253 + .../scripts/uosc_shared/elements/Volume.lua | 252 + .../uosc_shared/elements/WindowBorder.lua | 33 + .../mpv/scripts/uosc_shared/lib/ass.lua | 170 + .../mpv/scripts/uosc_shared/lib/menus.lua | 292 + .../mpv/scripts/uosc_shared/lib/std.lua | 181 + .../mpv/scripts/uosc_shared/lib/text.lua | 461 ++ .../mpv/scripts/uosc_shared/lib/utils.lua | 609 +++ .../.config/mpv/scripts/uosc_shared/main.lua | 5 + 29 files changed, 6886 insertions(+), 3569 deletions(-) create mode 100644 multimedia/.config/mpv/fonts/uosc_icons.otf create mode 100644 multimedia/.config/mpv/fonts/uosc_textures.ttf create mode 100644 multimedia/.config/mpv/scripts/copy_videotime.lua create mode 100644 multimedia/.config/mpv/scripts/thumbfast.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Button.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Controls.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Curtain.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Element.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Elements.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Menu.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Speed.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Timeline.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/TopBar.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/Volume.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua create mode 100644 multimedia/.config/mpv/scripts/uosc_shared/main.lua diff --git a/multimedia/.config/mpv/fonts/uosc_icons.otf b/multimedia/.config/mpv/fonts/uosc_icons.otf new file mode 100644 index 0000000000000000000000000000000000000000..4c4e0dcfe20684e18da5fd6f4dfbd489ee947c66 GIT binary patch literal 400360 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

Evhfm%Hl zoDENxyc5S`F;Df2;I3_y5365-BLHViOb<9!a@qsVHju01>fIfk-MiTlyQoou-G+KjFF8gQ0iZ|J4RR%s33ju)KV@uVNqdKX*(Z$H*tjBUw>r0MA3 z9AHu@O(~;Zm=+Amz$B@CdW3PKK)S~ovMDezUDM+0fM0b|>rSf5%s`8w-gJ>H$Dh`s*l^XZvnWnFl0L{u7QG)94)j(Q090MHdiN}MZ z=gyuf4tV-+hD}24mb-!;5iUi-Kn-f6(x4r1vRB3@bt2z#*b-fGi3OpyUX9w!n&LUpQfgsWIhIt(oFuPH$n?e*=9WO zp2a!69*-b-1?vvYD&S&!?6OhhJhq_Jyk&9DGH3Gt6Tnd`k|$Xc-^n#J7+-wFnx~gB zwvRc}?PI0g;7SV8H&q6lS6OVh>LJaeLfyDE5j=Tu8L0O<5B1xHH!6p z4ZqTqilr}+xHr2pu+A%VQc!F@1D0~cQF9(HEmpLu%d7x4&7+Qb<3?a1u#wIJ-1)PP z_o)-PdL(nqdHw}CzRcXZ2Q1s{Vz0S-#FD!xo!3~bqagLKvv$#nAj7rziEl{&7wcTw zq@v(7WP*|-n6OAuzlwu{fTzc}huwrIQyK`HNus?+Gzkd%&z5jB$j!Sm@R9vb;D5=$ z^Eq;D{QPE%Cr?uIMJELS8~HVcUoNB$i}ee8N8Qd=kgm&schc+h5Btj4Gq&$9?G+FaUaT2vf{!2cL5VmLh~+oUcQfCjM7oydh`U)gXPf_m5}CbZZknqb zou@u)4<+1K#QOJYTf%Xv;}L4FIA4yI;hWWb3%o$IoKoieDz-U_&e7ZYf6!ZwaB;9N z%ETUOinoF~6COoSV9U53VibVmn9{RM`0_yx$|SS~FqBosEc_Z|#GL`xsi&5i3fyRL zo}0^{WS5#>Gs>iJPh5}LZp)Hi<%c~8QQ!|sY%RTniuz4CNZaIZb6w$zKKsZ;Mjl<%=LRH!)ILf zosn~~xz?89Tn<}Kn?}!oINDzFYDsVu^3jwW>S9`Um*F9g(ff(*Sv{1Z{5XBjk&ogr zTqVcepj7I(I$Bc8rCO3ZP%5eK$v@PS*RwUKEo!T&K&ea`Fjh#L7c!TiR8r1eEaUPk z)baGx8FgsPD5bZ{`8u)ob9bd?Daxg4y~ww~JL3q-@zmn~u^jtSY;H!m%sx;v*jXL) zm1p!|#h&4}elp8(W+93y?TBGzt|_tQGRKqNHmO!>H8i#3#Hbv{DIR-wW`^^4nTeon z9fopA>ebPbc-r;@y0B3lU1&W{J7THvY)6!|G)I3ZOMITAN}Wdi&bd4CLxuSVO8zme zxsH*xun;BrWFkj~`ciZ~?xJ(R%utj}?T_;%+*wVuD+&E?F4$|ym`^Aj;Gdu^X88Cd zV+m?jN)q|S-8%~μ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%0Aaf{ouBq_<$=_pQ`g# zHrR}jhG0XzxxS^r+aPz@B9-y3O6et_WkRAR_!rscsF3)K_w`xxO0>&FwZIUfMJAi+vysL3a0Tv3E_fPFCel zcDO$dvAr2}>$34l%JEZ9pIIVTsHbM2buP_L{dWaoi7@&Q!}qv!bk3#PKS)Ij4COVI z6>ajC%3XUBN~EJb-5Kr(XZ$I*@fp~q{Ekfyyp-?yDtxua7kh24Y>g zdtZHZuh10>3^v5B-)mAYfdFH}Is{eyJgC~xwLauTP&JVas-`HY+BFqaMXM(0VY1aF zLEMFt#B6Ex zu}!c_h#2x!!<_@iHXgSS+wB)Ozi>7DmYij)keVjC+mBW;?xj*pM8Pb7=93@Y!evsTk+?tU4&SrW^s_0Ml+!oE zx=0Q5Cs`~zlv|y$_v^3QUR`ZR+?!tafIV3qM%+6T&xImaa$q1CN9fxX%B@N{a5zHW z&Rja&iNh1V+oEjM6B0X z=55GJQ&`2R#x3v;h~`XaA;MdgF^aP*Rf+#nnL|0OBSRMm0yxdGUul zL`3HB_v3NwZpn0tv~r;87)=x`qg5+cRW!H=lg-0VUe*&R9d(1${oJ@#Zay?!AsddH(fut(2F? zGKhY&_5%@-uxM01@wyNUwuM4wwLnbRBW}0+n`4#H`39;EOGWn#B(WYMvFe>$SJRNQUdq?M$6?M~pmF=YUs2q@ z^5qK`KDqoQz-V&fF|RwMJfwX^aD}`sr^V|HMOqd6@X~<26tHHm-)nJs;&B|Jenp6d zP$9ySr~Qd7ll(R?P!Sqn#6Li5;NS53PX)-a{)2TfF-h@bYRL(tzp_oVv+@iaunkb) zENPwSX!oHnpJq|TsgfXwDy|brnyNZd9jjBK%{{(kDjE&JRx^4!{GDB#Z>9dzZIZ|1 zcZS`@Xj2H(EP7D+fS9y}Jsx@KQe;VC>-(NwYKnT=5ePpZlI&H#x8L2PcrtZihn<1b zZMp+D6Bq)47V280@OW^JD^HcortZ#4eK2XNJUFFr`4E<456%Ebz2Zyu$Cm|f2wi>i z!fRTM-)*_+CvTbex9{oOF8}vM(}-uNwi%&cWYm}BbsMYhDl}{7ynU5=PDuXm;4gm} z`n^&8yUol^&xrzalVyR{6!o#7P9g67t^E9N{qWYY=eNoQ$h^6` zI=i~OT^>`rFX@cfhHf(Xf+2szl2F4W;6yl@W>CTz>Wq9YSuGO@OGEe?Su10iekL@m zE@7V&#zxkb@Z(8XTrFqEZg%UdY5z=>=kpj_%yEKPjihtdf)L zIFV%7cp@#yR&l>$=cYAg?buvB^j=s49TZ(Jj4`yqsG)A-5q1W3)Lt<(+uSynGDn*) zcwGT!#C=MeZ?ebRT^Wn|if%>eWxT(KXcBj_jZ2Kc?$A9E1jUOb0$kxmA_^#vQ-UiMLxKxcdLl@0d5cI+mhBuv$Lf+w^9549`H_-Sf{I}A(pFkCZ%nT2Dhs&)|mUartA(hso!tOG$*Xy zT=km|O1By6ni}hZO1E~;i9ok{kI)lr>O?}SRJ%s1So=1-BsCz(0XOVHu&J@(YWTSQ~iden(TMB zfP9o&9v`mVYuU%H6HK%we2Hba&QA={`i`bJ>;t zTx%Hp?dZ1!ZDh9o+wZUxe{2dpUtKG`Bc5FF8mOfCm&%ukHkY%x$qXn4bMzje=|oVB z4rj&|hlCH{c*Q5i<{M7TE;=zz2#TCJCk)RWe)O57tZfv>)Q!?Jj>9W%HP4@;ZWPD0 z-%9MT$=U2A&599`4Tfi-f{^=}o&7!g9ov(Ssn0?^ZOVl~)Qh4xfR-uRyd3rg?^pH( z^&=vMkb}^%#FTTv^x$A+J%z!r#?TkUWs!Sf=^Y|fbD}z8Q7rv;B4+|p`v%R1dr*dk z?atYwSX7mggp>Rz+CFAYGC+kwaA+=IkBpd37*tD6QMsfn2xDRjA0_9_(a+-eQ^Jg3 z;pEsHE5AccMmKv8@5BMZX2{KlFM70=EZ zPZMW@RVOIZfx)RKK?TkrsG7(p;{=MOzmQIA%MdF@t*OnhobaQ#{}# zL-_M{lTmS!l7LXSfl><01bGZUV8zu?dJdN@s-Z{$GK;8k#Ky!Iy#(Qt5`Wz#N^psi zzwQzUc&Pb*-6dw>60`oQOQ2lR-2ZKzn$fVS46dSG9Mn!u= zY)soaW%V*cXS%&pQQr_dt?5R2IdY>6`bxO&tUlfJ?#_fj)Gu(|YV9s*`5eLPXbPvy zuWmyXO_VKGo;)Q)k{v#~`TFf*Jc?4sEPVR~E>C0cB%ZeTBTvgy$B+~yj|3y1a_;)5 zrggO#@PWTT8;H4(bgM7e!Y^>z)ce?}3KIN%k4N#-|9nO&6WEHfQ@O2P6bUq!7!c(S z$K*DuE*NZM|D(Evp-^wHDG~`M;%4IUG}~NOpX_Pzc-+Y!6lyrmxb{``tES(m|0rP*>gn3k77T#UE5g?hyQK;g+!Ff2V7#pbSg*7J-1ffG((bRy_Ce{zL6l)>k@#?H14a0%f;GaMO*Mc zYMK0FF%wBAk}`76aB1b2QM%pliz?x0EEKoA3`+zpTPY?S5u|n&O}8v~`W7kDVeM>e z(Z1C1T}>F&{;4zXrJGJPy@BlzZ7{w+ z&$lPMFTFQcD{2K+p5TjnHknHAvo3F6+V$O^Ec#{Bars3E?n zhbU>l%_AvXvIohx*(EkXZP_iW^R;t?xG#ZnKk9t-98-HNmhLdCyU5k41c&HCNz2pn zfCC_@?flnFPAF8-4S+px8lPFBUZf(_z>o;Ki^x_-Mvwqv8Isk?eBh!^fa}0)wG-IE zXPp3iSbYR-Axn<=bgH*6T0&_2rnd5(t$U2UiSA6hoZcXW z?d={2ZiJ)lzIN+=Qveb;%Ko-pWgE=v%gWXVWo>yM7yp4;!Ty~fVL@9%$;-*A5@h2& zYyq5i3MyoHCoq-J=~eRlFTS|~)heWej&`eM=^0x*6=sAx!E_7;(iJA`9dOp}AjgFZSw1H2?iSotzHOCe%^(&p7u*ca z0^NQDkd5O{K{W=_nYrQT4%{ICjFinN{Xs;o)*F*cjtc(m;qCE(&TZT4cRESn3`B?+ zh!Bxb5EXf~9Cd|>g&o_(q$_D}lWT)p67~CN7N5=sQc(xGu?P@zy0DkP&^gOdtuf5U z=pHWu)w~$6{`_$@9|WKi5~Kd)jX`%Ne+-E!q{1G-TC3)-6&-Gm-D zQjVB>a2%5>!S|NWxVc(xsOfCPxt)FTJJV3U8bcA&A?q@t9QwoTXPH5yP6+3Xoi|xzO;6IroT*4 z-KtyYFUzc7Yo<6YTk8i--z&Ko1J!upcUD|`vw6;&H~sq8Z~o-e3oG8Z@Hg@Ya3MgvNQ0mx z1o80`iMQS|X(j@>rZe(y-qCYK3toTyH4aeEoxfcgU9)Dj@TNGbRY_~s1l}BFWjT>< zJS>%#@?diAa9Pxo3Gjui9Q}9RF@4bo(yy=pTY+lI zK8ec{9eeZ=>5D#y--3+ssCw`UZ&2Lw0-J6#67XUVW&r` z68a7m8y=!gFkXUW=r} z!2^hV62$r5hjfVZBC+_8&Q<24z<6xs%9VP0feVR^jhPmS4hE4vd|1b>tvz7!{aEU) zt*sSCMa`5mDze|dStn_whW-gbT{))RZ)moI1fbln%@VY$rrGl;HDRCrOTDT9EJir$ z+9F2Y=OP9qkX7H*+@Pq1v$MhmQ=id1|Nn2`x)PL^@cKZDS-lszkZ73IWcZz{yMJBLBQK;C1e5phRqCG0*APGje%d+TTkNL>Q<0Q1Obv=9E3t zVvJz_p;=JZkf)?LsecJQjWFy?ONu30Ch?g15S{Ls1YXx!Sx4G+ zXGSI|Qi&LB9)GhsQi)wtEnK0G7L0$Q!qF&%(hw#$o$@WFIZxHT(bqY?DZMO2W zvqvtX(j{lV5cFtvoyztzy}Uo=WP3N;+sgOP9_8AXatOMag>N(c88zSWg*#&M?Eae2 zXc*`#Ty_*`^R8zp>FgWA)U%^~{J0roa6ZBSr2UwcilKZmge2j1vA2sgJ&O{1k4e*H zWw2?1QBFW@6Q}CBGPJz0DO@)pxjeC0o6@UVpk-3m-r1%M{piq(kDfF_%rqFW#G-C@ zt5T;~gr9*6=5u(J8?}F2z0g);^tIqW2JM(d*fpKGOIZX~O+Y91s>mWR!Qw=*bP6ut z9lKX-7GUku&iZ+(*JzvOAhE6`h&pai3+G6qV%nKPHeeR~UAq;kGTGHfStnW(cEXFp z*0=$re~hvu@1HbeEL&sujI@*GG~>5v_B0y9u9_kt9xiu9Wq0zkbD&!QFMXS z>VY^clhois9NNVcCq(ByD>ikIJgNHSM^to3#?wI^!X>lk!LS&wp>7|b*o{`gLp`k8 z5uH0M<`LhHKBg?%yX-w(jQ6AuK)k0jP?{{;$>~~5*N9OvAdpU8D2Zt!wtWg@1d4Ue z6|1pv%{_&2g-1O_oHXR4aO46BnRnV6-}QHVeP@Fg<%JWk+E-J_f#d`s>mg*J?{O0x@}7yq3Fo2JvAr z8j6IW47pTYD*VrK>Do6A9%M+6gzM;CCLlnXovyWONfr*eFziO-AV;wNDMFfqeS+7> zeimcuvu`H(n^E)m&-wZrgzgEwZKwkr<0qhKJbx;PM?#^67=dir*6l^Ulq(SXEcwWL zT!3-H`aOH=N#6Z4nyG(Md{%1_R?#0IPj6;|{~UuS#GV7meb(uD05Y);3TREEh_iMv zlpC{q44GyZv-$#L7Lcac#aMu|4}iZ|T`LHoWiO+T0~%K#R`T}q;e!`Hb2MR4(J+~h zr{oPfnH?AWofnV+M=5$tX!2ah5=CJo27;E+T?u|#9*l5y{^X>79h7au2clMT=D5p4 z?|*bKK~L$=e4fqEVN(*w2b!~#mYv3Tf6~hF3RVtmgKX)bb&(c}tP&d5MKXZoIg|SA z$1J5QQ@_m92U#9a0KDn^#cKNW%bV(wd_F}8PkzSqr+G1s`M_dJypK=t1115&;GM+W z957I+^GL|nF6U`~*1JgW>O3rH`dmHZ)lef}(Hp4`F)hK{cp^J#_1?}-?vq$Bi37%6 zO$?M{dVd_726+J7#loxVNqsKO?2PPG#|>y$UtHVIb_$;j2eR_>_XZmjU z>ROk-)e-X=A78h>;0NX(Y$zyPC%e6Ej=0}w9U69{n2GeiszJ@IQLMi#_~|vhEN68) z4BVDLpw&|EsdRQIPp><;DBqmFcG1#xvZE-|HvaE-Uc0cBg~@Bw zK_a;RoYbRj@^yyWscJBx>TfSJUB}D_!exI=o;@EC8RQaLHk3Iv8bFuXJ6IA+`Bn+M~{jFN52LLXGy4oli+j#~ zSj^J1n7}k|D(8<%ZRxLIc#w8XH!xF)ioJo#i5exDC)D z?`j-fZCcK;Vow?ltr5B)N7mVOXw>v1V8T%5d{P`e1VKxPmvyc=WYYdc`IQcPXuwz4YQTVv>MMnY4o`=(-C=w~)E+5-J3{K53TR>Wq&<)IK-*Sr=to2)41`<8)=m=H&4BgCyg+(Rv5emly{|}p-mZpF zO{m##bvt}4r4n#lZj~cYY6d(b(w6W=jo!E) zQuBL8cItE%vTWZRjtLx>GnV8VMx5(T0BKaC9U#zIWe)apMc=<1245 zSGuaK^)i%XoFIeZj?tL0dmuTy+x&}#&tL6e3pGI@J2EZ)4D3!d2h$4?{e=2;u@cF% zL*|pOz4orGeJy9|(Udv6PZ29pe`NVMs>iHbRhg{mXjBFU1V_??d_QFQpjls8*5t7| z9P&W5(9x0X8|0hJqd5x}FV0`vS+ac_s*wtHy|x|0W-8@;{K%?hrM9hgb@D)!uzh>S zz60jh7C)QMR?b4g=FJiBKC0snZd|zVC7pn9fi#A+1-s<1uU@K2&>q4HlIyHIXkqb_Y%;c##vn zJ#sccE>udWLJoS}gIkC1DKrtOKYE9zDI73*E<^7 zLOBm35+v%eVxcV@AYKk3&dJ~IF(ALARvEO{9+ccHLFsk{Y#^mOOVnaTSKo{p3-z^2$M_9UyCn-!+mgRPOo zbTZA!fga0VL~;lW5A2b4N@_@3fnB{51u4>99gdg{h+ay*Ga+HJnwU)kBhSh_5*Do{ z-$josSUG5O!T{6P-Nc@hRNSBlHs4S%!*1_WAE7KZEcFQ_&zuRNdcZaUzW5>@<{(%U zMLBK}x7bT-s$_>r+TOK7dl~ z8RD(6)|fniv#RXZ657qYY721GyAUDH|TbATvpR*J>FN&Hvy#ju9L1N|B zpDzHOh~z~|N(uLd z9(EjGGs;3_pDLvFU)qll-qT4aw>)p!>MV`c^i~e~_IK+SAz|gJkZjZ*L$AWUMEByX@4@BjE2&lr1o!Wg~^q!4Y;geE>@U4 zj*vC!!GafxCfj>V+Ifti2ND^^EJj=31Pf$SCS{6Yf&ny+rHsYp$U}vp&iGi_s2(xX zXt;Cdm1jq?j_t|WFy$#Mz`ka5IjDZ)?GP1+;bgU~np4_+FdU5sASOJ#;h`t>%92Nx z=#?e4>kQ6TZz>WaV|fx0vI+GvvEhln@o>zjEs59ORczMo7i;5nX{)@6C2|?4J*nn9 zrqjB6#B@5|(`$ZJT)m-lM}?xTG(5)gyIit@_)>jTOmzH;2jTg7K4pTK*V(&v7`I<50Ipj1ntdK1Z9^5X6Nd3wb$H0li@i zCO%V;Udc1CBJ)hw9joUgRk*}kq)<*Fpe5vak?jhSA%krpWzM+ZvlzLBuVxH}2&rGH zbq|&Ud#p{+uk3;PBq(9?X^HfY^U zb%Xd&$>@C{`L59FwRfWw`k9C0FH%XSB?=Eh)lo4`7~d2r1NEDR{;uBspu9cQU%Sn$ zZ4mF6~@ z<8hK{q(W~`L#8q6Yj;@Iv0Z%d^uJ1(NPDKOvkmlRZ@l7&$%pzFUU$VRlaG~lE#Fx^ zoRoi|ZI-J0tEwu>?+%pj%&i)zsuG%;>s)n~#^y-erYty@oK^U19lua|TopPstzo->7f>gsyxg(z^uR3yX^a7T^x-F5YN87Lb9 zs}>?aQDaX}T@QW*+ZGz5TcuYS2aa&R%Jl{U-zI=#R^HGSN?R)oOh?QO{hy%<(TSf- zpnGTsPQmYSNY`xLAtqu3(}f_As#HjH3CIM|CHRHeH;?Abxb%!0V!R;F1|TA>)HT`y z?5f}}(7G*Si{@xqQlTQH51jzUgr{KgVb`Tpq)gmaN-(HEv{I7d;41@iB8U&Pm!*6+SsPjWbg;oFxP%%p-A68@a z%#omN!sGCe86F3qOqNADN!F;o@>1mrJyBQ99z$PC66(8{_H2DY4-ub;Jw0KEvpR>A z5TeuBLN_Q^Px4?rvfZ>EFr)>xQku{RzOgtyYi{-{vOZGWtp=NdwU%AQQ5LRX-m+i0bEINcwmlGxhuHJG0f#gGvh3&%eQ|pATlMpEq__ zv+C#Xskcbudxc~?+MYH)AwJ*t)4=mJ#@j_}6!~s>e3Q`CJQ(cg*s;Te7{<=}PVIak zQP%_@H1U=C#z1*jgK@{!j;6tut_(AbSZ0w~8(%hP&04cw=$5N*sLg7#N&E5)>9Kr_ z4^Vcn>oQY?9`38ZkznHzZ~V3=OG}?x&T);oTA>=0j%xp4_{K$CwG^yiDnO(PssM3| zqarVOvG65e*hswf#)W#l4Z7w<>VHexMfmR@)PE3OW6L~)QXxxCV~hsXR4Dv)Q8jC3 zox!f=sd;2_qdDBlCrju*)R!ccaOt`psaZ9f7O*XZh8q&x6i!6)11WHt9z=$G&7h$W zm_~SYejObwLXErZ^ktB^AbXxV%mgvEEW8N)!ss8EaWm3TUp7!b@ffARFC(#V#@ zi-${}eRgCE7C+Jf_zf?0@*RDT(4=nZI|i0B|Bfy{H!||y-w@y?Pa%&%nuN}}FAQA( z2#RpQUuq!vx}Ph;On@rvoXj&oKTz(BJSt|mGDGY~kTecO{Rvmw?eXQ!_m-geswd=~ z(9Sc3-N`^Cb^^80)GM{WmueneJ+^Yd8}vdUz5rjleGzBeqF!%6t<9KU32u}$lL0Dt zC^i0$c7+i3Bw8bp2aa?;<9x~(ZS{t|mJiSSPig(VjY|^CjNUe=#amPp1Wpq%pK_Uc zg)izP8PqM>dZ};4q2gmzkrvQaA`|L)CSI*v!)onyLqtNo7M07Ma6OrMXm11}_x^~n zKs}G|qPg?6d45mG8RrFL3o&13Ry!f^ysX+_+udZ?x)X37Tf}C$++eeLoi1f-xo{IV z;68E1$w$V6%35Semdozp#b%iCgG=uE=q1b8B`LYiSm`rtTUx}Z{zN{CvhYjJ5C~EW zb1IhIw)Lrsp=CqIpBlPtN5ydld;jxk7_yr_Vl6Y$Yc?y7EB5B>U)a52!@~LvvU)%F zr**Yg1)hOs(kzlEYV$eF4ahU-2qR^(!N3(Ukk;ioy8w}@mu_QXdhihmzc;~>`3%Ga za8_bkCY>ug3^3$J!|5M_lzEAOdPgWFi})Lnd$}SO?F@xt+MRa9F%jQuOw|S&?YZw^ z9j(X+%=bkd599(_iiM>D;fFE4r#O@mV&S;gWnKaPjaM%#NQf)S%rS8f>@V}#g29A8 zn8`TQB*grQNZfo(j6(&%FIO}P{i615b;X%2$|arwzG|K9q zT7$|E7V@vpSI-}g2SZ6uv|xflf?T|qwmyuq*wV1oDS9;ZC{Qo%q6? zCJIO!h&wWs!gIvz30uM*cf_nMZl@6?tpoB;G3eXyo9s?lgHE^IWp&!2XWN3(e8yIs zDWf`HOCpwvwbhj@0pntLGrZ8T<(2=;-JgftwD{EKfTV#x?ke%a^m=^pD6KJDshqP=(m3{=Lu~g$Sg%^&+SP zl4O*6!&>czwSoih$zg80NYDXM7d|6gm-_M2H!W|!{PNpx-|+I406gW~vhvvT&mW_I zD{r}F<;q(iH8S~6d%!7|IS8c<$f`!wO9m^0jm`K<%d zns>W@M&MYh{>g zMKy`l_DWxEo3YIYJpeORXCbJ6JQRn(O-ZI~N8?_j_Rqv7_)kMeFcZ$mUx)6D)9M8Q z_A3I4Yk0k8?NX5dl~kEfP7$wwaH6o=Pknsh7gAms8tfiSDC)V?S-Q>*dr*#()h;zS zc9r&Qa&Pn2)_S)Y*SJPDj9MmhVAC)cgS;s6su1|Vs;Mo5r|5C|m5p;zRdbrO+lLZD z(|@TJapMOkgOa4*jKOuOtoDKiE}TBcZlS}3iF^AHR= z>(YVp6B(}p@-3?J7_oM=nUP8i1S}|K9rDn_X|f)xxmR7eejWw1uCK9L6d`h=q}< zDq1z_8QW!hlf6Ueve%}nEfjA@JUZl7R_XSKo*$Ts)>%2YbIv%6!e*USa$YeA5fz2m zt>Zk|XQ~afTJ7C2_1$8_2RYiifC^IR|5t|1MU0h~bSFHSU^HQT`pu{RU5Gj}PU~|utW>>@Z>Qv3eYo!9Bo`+D4Lx&K1skxRIq3MI>@*Tm;B1~=Bd3x zl3iqUQCFQ&{gOp*vmueJJ&G(?3*K{96)CHdgu4P<{gL8N?_F~CaUb{D$DK>9nduWj zdLhgCB-aPxs?7D_`xD#02z`nnN#tK7&3ISn^vn;xp*bXL$Ob~CBThTaL#}-#K!Z5i z5|A%Xr`p?F6vuOSJ$!4@xb14GU_vA^uRMq_vbD|bu$t|0XR1|x$N9<)@ym?%V8WF` z`Sny9a(C)^SH7*CmtsJdd2mCh1Ob9ESQEMBW)*)~@xskRdB)$N%Q11r5#Z0dd4Js_ z<>M`&rHJsklX926E>&gO{?_Mw$kt!|MGy9E^%v6RN8Mo;s=?%5ez)5faKwDMzrH)_ zLlLHM?yrx=!l(-z&OM<1!{6rNw0YWEsbHT_DpLB^FF#r)@OwSF%M=TtP`Y_oEZFGw zqd}ouXf)+ay|e$TF39r6lSqIGV6* z8qTA#;bccc3JK^1F*|)(%w4k=?Cpd&%$0MX>b&v-$ixyD4{Q_SIy4a zlLYme2?3LfXP=SHxb*b}Cn*@t;snmq1=9C4NoaN^>$)uJ6^5>4PsXWyPnBF5B-EO7 z4&|GGeRwRVnn zjtO~0@v);F(X3r{p$l&)wvM9k#1SUPXbgU8HmN6f8I1O z5O@HG46r-lMyzedeyHpQ8te{cGG3={klKRLXd5WzxlKK;fqwHqe4wLSjznAWaBwY% z-RyDZ)@`$wmYTPuN_)1+QCE9wJZ4mD)LN;f1rOkix^s7I%B)^vUSln;*erWoZFu51 zAS1E3anESSk%!Gk8-BETRQAMeZ4mU(cC| zV-Qdi^ldD+Vz+f{)I6F#G_*&?M<@_xRI9WqDel6h6Fu9L!-3(JVdrq&b`MnITyA44 z^j%sV5l?PMZM>?+T;-~QmZ6tFc*(D%y_f0*=bvEO+VQJ66;lQ4%xF?CMIwWf|&e*EG1%Xv14tFc0HVCm3j~dl$AD3bt8ib*s zDHd;Scg3!IUNZ!~SK!29aTmN=95(Uj$-u-4TJQlb{g@J%2msCFg>_lla8RD1+y=vq zI2BCj(I)r9z9icXzAV|evri9*Va!$VNk2Nx$BKxVvsLPXQ`kji6;(Erz(s1YFKEc75epR>Xa%uIkY z2^~&oRA`S(PD|3w6*ufE-zW3DBMTa%${qWQcWl47Z`aQKP%PtBUXK+?#p|%6p0-}#(z@*gi=890$_a8n7XOhP%tUY2QY`N`X$-7H6Mz76ioX$ zVrHn;#Xnrlx7d(+)(~;1=TxNMV4&9>#*#(<1@~2J&+&DuRaFVyJBuH+4X2i1?x4 zM>iSb2_X`0MQLUH+3NFERhjB~41l1KJM9mnE*5HZyIiIW3!77%Ty7R!ak%JIB+Jh< zcCi}Qp=|?{VQBODyuMa<#20s(nhaA+>JB9T1h2OZU!XB<;V_o+9@bNz3P$PC{!8L@ z?7CXTitN?_N_Z){fX#y*Sji z)6rKmvL$|B^E#issRhV1zIgmjf?jx;wmqPpua*h*$0Y3rZBKSP{$G7N)^1SuaDWTj zT0R$D@XymFkS_sccT{X|3V9szh8qO8AMebveT~7^;g2Te{VzkA*&BCRwy~x6bChZx z!Q#d=ezPgSO;~L76reh54&tyg|0XYtmK~dNg2*E?kA>5r`}lf(5K*yrY2vU-xF1s` znr1r1Vvq}bQQWpoQ1^nzfqIMjN!kfrkOKvOpdQG5L_H=?-Cd|&D}DcG*~AdTZvE#K z*f0PuPhiijIU@n3ZO}HbaO3Clhi;tG+|7U805|3n_ z`b?rsANChsCJBz}CwOCQD1BN;@iv4M^Iu0fMcyuo6>?RlGuvgPhRrC?^Qlwm<4SA1 zrG_K`um2g*!I{~&$8X1KgoPq^3=ImIO7TrY*?frZ4+W*C=4$!7P^4ujo$>O`uTBpKQ0&ljT#7^Qpb~z2g4l;C|Pr zcW2c=d2D^Wq_(-#*aYSK4jpjR9%%>Io%!e>bt7712SHf7Rr@!=+L+kdYk`z*Z(>Ip z`i&>Tun&tR@hj`EyKx5Ra2VgZ z`s%B7J_mpK#`oXXqrdas*8?GRll>^ik`>kloEgRL+dJervq_tqG`I=jjJo$7vU)9K z=YC)yWx}AcscT==sAY8Dz@9E89K^O_QNQt|cXR$5lxJ2YCMxY|7#U&e^q z;4Kz{Oc0Yu2|{MT63#?3@pj|r(BYwDmScx2)(k0e)anX18zK51v=G+_AhSxAYa2ej z94T}#!%q~vbPdY=1Sw*Hf)hKWfnDkS2hG}qp?L&_LfjFT1#7sjI-u+@1V-n(B1(Bv z!>0I#SVe!$kmo_iq2b7w@gYN`c(nAP#89-qFE$h#cI>R}GHRYH5t#WXG?1iUKJppf z5rQ!J+^Hwz#_&h#JPvdAhIUL(A>@NSNNIEehuFmR5PZqG~i3iAyU4=t4F&n!lmv(q=tl=>j*2_Hy;<;P*U1sDS<+ydj49};Y$P(A@%a7O)Th3FXAKuZ#CfK5eSY< zdRe=;MqGB!s$~e%@+>DIzmVlTB_6-$$TE2XOz~pM+~`2Df+AoQk(gl~7W)IRFnR-W zLGdY()rCV+e*ez0je6YIeAV#YuGflA&ow2huJyAU7ENSRS` zoH1#$!G4M`>WTa!#AFC~%q9YXk;wz(-r@u~4{Qrfke8_|v*TQ{%4qQXjoW$p?$h6s z=FNkCmX(!6o`2sT?uX|}@4t@|(yT`D`&`*` z9u;f0W|X}p^X5$@7s*)Tp;j^kvqF~)PNv7HDNRjQAF(wSXbMY+$#(b-DB)g&Bmg-p za>_uZ-4#2>zL9H<9sL9?Bq@073`%T=bOO`0ksSc^WKV`rhMb&nGJGnIrDG?fSP5VH z>`d+88)&22jzPsCZ%7NZnt$-J5Df!oYEE(F5M3NC@fp@06t<5u_lw;CH%u{~o!tPd zsZZ4YRW%9fgCG72(7~{E5^F|HT3UzaCB(KsTbsGAcgoB|PeJAEPuib!JiT_Gdc9QN zE%?wCxM9&m*ibP9U0Fb7p?y!dEcWE+hcE;G&ijAAgHK-$h0#}<3ZA&mvUtVDl@&^X z=dQv<<{O{9?Il?qHc-O!iTe*1hS|>gspjl^M1EOh*ieo#xhU|cgbWWP_6&`JgR*v& zk}^<~m7=a}x_sW7YKt|@f99j#4?A=E`HvN-*u~W(M6=^ z5b%b-`Fj&DO-mPmpChq{mU+jNSGcTI%I?iVG}9UGv`~^tMs41LjAD9~|$M zW8p|VVeV*3HhbjT%2zMD$$aO)z5BPw>uGJI#S=7cp0t@1skj_dULc1+LDrs7IeF7L z=Mk~5u48MY%s6iFj@LX;I%M3>JnGN;LN&v!(){@+3dG<44#5%>OY!~gn-M4=E7ThLMV&ovskM&6i&Z^Ft{mZw$`t&J2zqIHew$tz0t0LdVhI^q0%jrGJr^8mgC8y7q?s`~Xa&NwQ>yB)gg~hTEhh!Q*B;xPK zj!qhT08WPBVg?n`_~~rYwG_C=Zrr-#$1jj|3Floqq8*S{A5yPZuRpXJwcxI2fP)fP z036Ypjj&}Aug9R@B%Gcl&V>%pC&lz85P~GTijxCoMhrjs8yZG4Ly(^q(p#t9FRkxi zUoC*$52&H~tq-(siB6~29iiOjFiH{fNtd>oP(X;&w_Jwz=S@#hS}i_2|MdBKkA9$? zFCADbY-5dvs}YGPX^049pWp~Ff3Gm8*N#gyeZo)3l-AWI&jV^w0PcDElN$_qlY;s> z6Jb)c*G27I^;{u5eZVTVH59_0pF-|jWjN|6dH}(MqV;z>l&hvK`(GLEPL8if;9&fL zqe_h-^9p}gsGS!ma3D#mEu5DIV^Dakk{?9XxAFt-dHTCbc_j5I}e2| zjd1zShDGJi&fb4Y-j1ev3C%>FCd&pzKOiiQgCWJ_(?DSGWEbc#BEXGv4Iii=(9fbx z{1s_#5pC)mHZqq!FGK=}0gA9!(IQB&>({_W;&G1?k!iOT+16e&~VP|HzgTXDeC3K219%n@~j z)6TBi^w!MYei+S-_9km{lPBR%wWW-^g4_FcS-R2Bp>Rxzg%iMeuWNNKDg=i*6mcou zW>=+u+mN@fIaRl3VBkP#+=y`yiCPXCl5osg6_56Jm(ADC3(=NI{la6w1nSCqxU}iP zA_8iAmb4a}LXNZmQ<199>3lsX=<0mEn718Rz%K(ab8seh?x2{6q+)Jm>2YLjU`ugTu_!PAZUvPd z22aCgrt?+V77iyFfj>HwA+btO4DK;j3*lpl@BD9W4ZG^BG~T1mdQ>EZQTd1JMG`FbR;AioU+<|hqA;A> zVWC_nM1=xM;^4^0D4^VMaxJtJIKU$GE1l{&lHKKWw8}R*?^xmbq0!Oebm1(r0op|J z0F>0PUVQQGq>*{_?7florS~S|gaEyL0&bMSec#$L?b* zW49PxZ81+Qd)ynhK6h>QID~WKY~A^-dQd{)B3Fx2b=}i}beDjNJ8fZ0JmSVju8MfM z;0n?T>hlLfEsC{sX<*>OyV2MSAFYS9U z(Xe#8g37>kO9R$Ufr`eUE*P#DRO02X3c=M9amOv;P+KUdq`RIDRN?9^QDh9mxIjq3 zrJloJPNdqD_BKb#hT(u4hWx$|*FY`Aw3z$BefNHFfzWR44W!fU=|Hco-MA1pCet?U zPkEBc^5vU%KlP*#wcA5BOYx>n#jc<;=vLOQS$9CsCNy?7 zy}A`b0e>t&9zqSf)f!=wuD`vw>Vs0V}qm15%-3i-fVrNli*|* zAd(fRerlZ%_J>jW=cxmxzEhw5I1tgvS_~V?VLec!1Coph7{h&r@yUVlu@hBA*fIeR zfPs85kC}ZSvWOW1r_4;4D^mD~jn%!BbiDK)+MIAC=Dck?SEl)+QW9sHaPfM zu1FTd0p2}qmIdv|ba!^f)xX;qp>vMH0o$e&6@YEuEnZmv{oKUsJ zMAfoE2oY0(~EWd zPzy?ar`-p@Wylu~#*Lvw8^R-rRyaDSN&(DeI1x^ILyrHCy!QaF;ylxavk5cj*l{-5 zU4}^K>^FlGCov{C#ei+>0B%3V*aq8R8`C7ZB#L@HI=$=kuHKM95*;Kkm}1xKliNQAwbWHHX{U3>}_!M;q*ha00zNobrV1iHdMpLusJQUE!$IpWcd-Y#y8x z@+5FlW=C|#cEfhp4$BVF6URxRL?qz}+x3lwvBDxlk+ZPAkgbVRs9H{4`Pov;*3r== z`eSWj)i_kt1J%k<`7DJqvuiYq7i*P!qw!)avvU>+J)IRLB^C6iv!|!Cvq!q1Y=w_A z!0&mzbz^`i_l*<$2?-j{JdBjDDa^e z^mDNaP|ll#l3UoDSX<}|t(N~JL^Gi#UyEn&mj3n8MaiX0lZ&G3`?u`%wD_BXnVA0A zlS`L!W~fxx`qq0kZE>x)EjqUJ#pK@Dj{fl8K(nvOnb99V#wusqss|c-4dkdooHs7V zxgR)?cEJ*@l2nwHu@p=KpsLHZfhl!5BIlPeL!(T0lb~F$TtAdYxqL1X3BmX6EouZk z6fLe((DlF1Z?l)TLh-e{rqnKdUkd{u@^DFQJ=lm@xM9kqY)MAe_{W4q73y&E3Fm~F zm_XBF2M|@P)flY1OX{Qw5*GTPiX2ueLxj*kujRIF8*c$Fs`166!9 zbUpf#JcU&{Sm~N{f1-$y{7vZ@-Duuddx3n!PWN$1lg{W?TVdK>^ypiSr{IK zBRiA_+>7d`T>3)y@E0bN7fNd5;FP-L74QsKZ=UOi;QP>*xb#}=vwgb8DMmUXZ-`5G z#Cz=h);)zg)`cF9KDjjdaCqJJ!oAjhhzWOQhVm}O4JcnZC~Pk8>g(&GKjoV@mzQr= zeOT07T>BxzFn{~QF#=1fl9M_v{-f}|?esm<4bvaEXQoY*e@z4N5AtbYb-ossJD8u7 zvH?ttG)_aaEv2t+Yir|@$%YmihG}hs)uxwsOtM)UYLhm7TQxqJ4UpX=avv=Xsigkk zpq6Y~J!r+z01cy-{0X7D4Fqa*vU>2~Y7H|qcrXXIX@RGiti#^w>YPXAB&t{G{@}jA z7gevT-d~@8P%WR!&0noPL9SEz;+3V$cqM%7zcQ90X#wDVYWftVx>RGxoNL5?k%=6Q z;2~c{@EH``T>EWC`gui7;nl!8bf2@pw~&V`<&mhXmPyp&eJU&!;_KPD47ydUEKTW~ zDV*v%dZ-8C^KdL~Xs$`tJH$r|*FJQw;okiZJzpp`I$D#>hPWq&qTW?Kn~$C{02444 zJNxL|xsN`YaSlPU4<_7x|Eur5`zrmp|MuJOzyEd?=Xy#W5K7`@-GM$md$Se(wfm14 zl8%HefI`aFsrV{iFeulu*Szlh@8vIg#e^$vhag+e^eZE0LuzRnMd(@^!TnFswDVVs zHlRh+Jo+ch)D#(HGx_leqf@qWwuABC>f~!CfD?R>NLzUbjuMx*aYj;1_PY*Q)ngeHM$s zrLzQ}2`7fE5nId=a{v_W*1J<_U!yUZ2!s+6*7`7D`1mgjD*&r{+^~-K_#t#4`aDc6 z6#sbqdY{ML0Pn#S2)iW+1xKJ)8S#OjOiwIjF+)^`+Ot4RJP2*M&)PRHE(I`%U?Wl? z@7-bTt?2~BGr+4A04hwsqBF+{sJ{#UF{po8S!HW+YD+kG0Om9xRL+=PDniBs&=c_U z`K3bL7H_e&q6RqDfKDwkP)h|WDst3n0j<8EyR4?tTI}7DTgWHSJah#t`lH;DJNeL- zRB>x%S(#Pi}aiJWcr8C_d+L61Hrz?=JAmSbIexfoR$t{iWjk4^R6;jgr z>#q!@t^erCklwoM5#3bJ$zDU+2lZ-o8)9jg5fB2D-k5a7Va+3y0~kGo(WPZ)u1`+^ zmPf9~#6fvtK0h*nt<+G&%l>c@)sT>hv z2_G>1O1=yvVAP5N5Jh)OPG$`CzEZb*18B2+^)Y*l3J+r)Dz_E`MohfGC>Ckm6em+0 zj{tsP+UlrNWdw6S{=7U*Q0cioJ)IYuP!PL%Nw>SCe`8QA4V2egEA{n`R9&+%9t%Z7 zh>XP|aUKZ<&)r!K`Av{xro&W(?`X(XY&EX?nZWWfr@I+!xMmOJZpl8gS5P8))dqgMv3?G zXn9dh6*loRaPL@fMvd8Lnd1`bwJV??pe4Md3-X63qo%xNCxvU(B2aqZCtngMuz!iY z)moF|b@@R3Jj&a#i_uFn{IB-%AAbS#kR#i4=Vo$v$CoLgwTpuIFohC25IUm7TDeia zk(0kLqCCmZJg2pe=2@tRv8N`L4=v(1LqT9%w?1{&zMDsNwOM zjHdcvxL$hu+#Sr&~RdmFaLNfL;V22rFZ&YA=_5{%0ZF z9BK+=^sj9^de5y`s8y?kD%0LS`jgjoive@U6t2@R-CVHbK|{76?S}==F6|Q!D&GQ+ zG?8UL|N2nJ-hpnhCXy&=Fq-P@mF0%z`_>_1zVAqvy~ETjHY9plBho-w$ND_2Dr2z# zfl8|KC08zA=9Lg0VGbDu zs1wX0yh70d*CJh+ORAPs6LM@MXq8PJz}1n*jH!)+%%5BxC7;D&1he!%D`%3=P8b($ z|HR@b^>zRACaPosG0p!(DvA702D>AFbM~me`Kcc5LTPZ#w!=>aL(Y?m}EC7>)ZA=69B!xo@vt+4AvRVSfLzSFCUNP>N^w zIZ(eCuxDl+oA=tfJ=IT}PlWWd-rcfQtAfYCAeAi@Q+Fv95n1S%{wisWV995dkAqNs zS^}B|26aAWY%7qHbB7&CPgvixXYZ@;8U{_B#f49-x~)Qdui}YAg~no2MgHx3@9)_l zp=!zI_UNtl(y9W(^8IU%>}*drAhf;n$o~CDc9phU6R^dO36%p|PMtQSf~ZLn+nvum z8Ty*d>+oWx-gXnhDWEcp#G$H%DcJ3FNdt?}y_=#@uu`TIP(!l=jWzJ%mB-1Sn-KcT zGsoLwAnpjdtcLkp7FQNQ+Q1RAvHTdaiBH2;hP8Zkhz?sJuZnSW=`Oeh7m^n-Bbm%n zR7GAgW5X%|97Ji6la+aV)aQyg!I94(KP*`SuagQifGr|p z6l?1+94Owop2`4eq$D~7`S1VuTltG=miI6lt#*giFWqFnea^Rx_dl}h_(o}f8bqy6 zVWwLatOY|L1#$a@gRigonek%b1m*9)b+htCi)8U9tr=q~5l1fNcZqj@`bXodFBCkv z8!pSJrH4E<8KQueW`m3ybT8%Kd*DuE_1A@DBA!ep95&IR>+&>0^DSog)Wh5JIzlKg zj=OA$8l$P)QBl?E*k_iG*JesK8yc5#1VFkQ;%iF0))c5+RLR$HyX0jWBVICojY($P&$kCQen=cegS9NKp* zA*F)xSPCpuRg#SExa2bJUb2w}6?3PGh$ zRXYuz`UgV7l|bw)y=q=&hH*uH^_I;NxSu*>z52$mEn_l5f!ywsEUV1(W*Anq-lYI4_3TLst9@YO%#zZ2T2ukTgnKS1M zPgf6a=@T1$ot|E&zR{C`s-aS##I6*Mp?7$l4AZahRx3t`bjx%E*FDHNDPPecmzZ#f zKc5bud&-kC%3pz^csLQ4e*SZa*MS1rAb&-N9rn059a~uHu~F$@)p+)}e&y3ESE87J zc8eHqF2-E0xv!$OtsFPb)(_> zb2C2r!#i)DPl>^n>P($YZ>gztl^Yj4d3?Al+L3OR5{Y+V5?~b)=hRa12nKh z*#eSfO109cA3^J~JJHR-*+D)uyA|a-!0Dk{OzOQx57DkLdI%&b3Q0|oUW#mERJ)H| zhsGI;qlj`pP+Zub54IcpOAjf5jBsp;02u=AM~lCN2r4rIs0W;dKY@d=hA0Ufo}EEK z<3y<)7L8@DF5Q$0Mb8T3SQLoSoVAmB*-Pz3TpegtBnz({OEPSYY)zJlfB1boo^D7u zb5E^!?KRGVmSvL(5oOX$1eb*-BNpyT>u$Zx;jsAavE0Q+rcL7>+yA5WXN}~4l1GlO z^SE*q;xTG_d>}2}PLl{)g@yIW&(Uq%v1KjPaOz2@lDwdDvCeY-#0Z1cQQC^;IYirpy2aJGHM@`vQl*3WW!o?b0 zJcf9J7UBvl^iSRo*sRFNqmR;eaU-0(v({4&%{5eeYFxGAbA?>GH@XArAYZuk*UIOT zu)hCYxNZLF_uf49%31JF=#==-@BJv&kWGMi+2h2NlV5fzFd9i(fbWnztv|Gz2=B1?bHYOR%>@M(yk9R z2b-G>=O29MmR_+cT4|~&UCGc4AUbrW*~Nl}(i{aq5=)r#2~n zm%jO@bAD@i)&5H7;e!wz>Z!)6UFWmeinipLmN8L^PuDWD;dae9cWwrFRwroxBF}Qk z7V1KGVF@fd_cK1zzAfHk#Ox7^`Ta3To~lJcE{jII!&}>2om~bsm+)sOyq-gy1mq(o zv47>yK_9Op)ZWx#Y#U3w*YK;>6s`0~OWgWJQFn}0n$I7s2j_tO;HF?d>OO+ z%q#WcA&;1i!O}e`HY+ZH{`{EYGo}8;XA$J5?4L%ONb`!g1fkum63D1H2-HxgsEYQ$ zZc;fL72AmyC#rhoIfvInD$U;(9B{47ePZBW#9=@O^63EMFo5X5ZH-EqLPjym^@O zrL8fG&+2nngO&NG2v%PD@RoSRPKF8umr76pT)c&XFjbxv5}z`T*roq?NV$J7Rn3)Nog17E5}X`u2efOZvCW$MhR(lUTj6|}4B z&uXC!XjhR>kQd1h{trCZm2Zr~8D^MJW`(RP3L}!GL~A2~Y*|oGx*&XpCV89P zpb#TgRr+k|0+@BWk1x$1lyiu&H zb@0TFv}gPFJ>r{uCKFA@r8j=VMdP7l!tgvM9z3D+PVDd=15j@E&MK zN$$g7B8+7Adc>V5&6tpVQF7Ip1&F5{fl z+<)g>2xYU&YI8cfEStmG02;b{XUDc3mcwXlXN?*8ME?;v1F` z`zuc!e>kOAdO!XLR&8_%$e{=z&W~V(uWnhly;xN4;z98?@yf$a;;==_!Bl_b=!-8N z1=TQB#JdqHV9~xibJgtU;$rS3!2FSz7etTCw&%R5138188HK|aSAbcCK%V2_ zpbYSpAe0--;c;-#gz_%3zSMnEv{=DU7#mKM{T1krGrq&3;kphJ-V^GxHz>0ICk3R%e8o+0wkBZB!+sd{qs1%3tM& z@;SEk(5lPuU4VmWbw~2+Q#F>EuhPk9=Fv&=SDD%O2f0nywd;CFH7+L;SayKh1rlfGoFAf~& zEw%xa%rMm&SS^|C-vkD4`A+3dc0~WCE05?c#u5J_$Ho%Hwq!kmZnK4D?b`B%}t20Z|CJoH=1{grrF< z%bJ--;Vq12au-dl zmV=#`Y^lW$kb?wIvVi$`;wJhSN%H29rOTEz{+ZSQ5TX=lET>4cW$~O-_<|?6dK+WX zypBcyGkf!Gf32l%Mwx=5%C}cCa5M$<3P7XBTc_REpi)DYuw;0 zXy?zo1a9ICcDly}<_-pLr_;f}z(iG}Y>vBw7Ou_8W`6%#ZX20zf8(uKw0pE| zGCW~6r(F*p7hrFnv9^UnO-sOlwxJKC8WSYnLN?0GLBXpl-NuE~n94^pg;L#$r`a7T z#xo3j!f06|kmEjsS^$R};0c~p$LuaZ=O@MNZ08>{E+k1EjqJnaQ`Vb|XLApL>SF;LYB;E@H94mx^ z<(xISJ9P3Bib*(gMpN0IcP3jFjhGth+)h04#N&kE#LwDqmXeiP;$bT7;StQCofkt9keFN#nuHEnWk(+V zROG9V2FmG|MgoywSWNXFO*o(N>@3+{7+x1Cs<0O6 zE$*ZxO+gw8S|Lh@!TAAoel_T%{8g)YvsiV6t0ZLrNp2s;LlyEDZ>nI+Jc~_Y@?nT2 z7Q0j@C(=|S(dL=*O(Uo^q4|%L%@*+eMeSQ*tAeD-X*Jatl+)U*&mwEt;v96e4<;x) z-<{-|VyUJUgB%>jP{7psf!Yk%&6;Cm^dIt`&uPEG27oRkkAv91xcRX9g4&#{g(K+& z$0wRu(o-GyT4WBZn7nv0hBjHTQmxKxanw~Cs^hg8i&&?Q8XDj*bDE*dPPSXCy~Ugs zThzs`Qn_Z-B5P@avumoal_1``l9~=`*D#O^bk>wLNyTZ9yM|3%pu?|LL>CIsbaP<% zu;KU@yf^vf5~ehFggN0N<~phYpV`ScaOh|0AUZ}A|Ik5BaZ~32W>t9oI73>El3ce_ zTBCDD+%d7gvWS;|Fe0o`x8BMw#R`E2`gQ1BG3kH~^q)@Ao(ECG&I-O3WfDuG95|oc zcKe#OISZLs+W!3zON(TDB$n3Ut7*xKrJ;NbVrkz{#nQHA#nNh9E16iD3^|D_a77AP zc30Vo;)Tl)NQ@=+vTg8ysr(4EYyb#k&p^l;QN-+T*}bqRCS7IQ)$a4@j-%-^jjrwD zA5|vE*EKvUPvCx|Oc3PH^Q(TrH91X*I^(VUbIJtnFGZmBgE+MOpbU}dO-czAqm>7B zGPZGd%2DAtoOvr>XR@1|(yCiHTtMr&G=qSfP3}i1UCWUv{TQ4SDDTrCES<7Wh})6i zG%8=!A_GkF5c8ykA_qA1NiB2ojep~O%6^F4Q1qErDT#ToY6cI{y$Z6ww7^jZY`W}G zo!SzQh>=*?NzbC5q7Lo?wE%}$zgRs8Zp^@axG}2-!I)mG+$U7k*VVY;tm{TKAu&x> zN6gLbhId#k8c{cik2jxWi#HXejr~GWlx`vv(w_}8L~UzlfHnpd={ zR&qpaAzM%{Puiq5oAnm} zdmgOIZ|0XxVE-&RDsA|Moj@BdR*m~yR23~& z7fSpnFIFvrT;;ZT!pRwk`>S6N%e~q`E}Z0Fy6R-sLMP$)SN@I=Xk~3@v zh3|^u#~N1jsz2-N%S)XlM%J6H6l9Y+_`V(SOs{mZc(~zYuio6d!_mj$$60%iSI!R! z0ls%zs-;az#88Xx)YrCcv-TR*O>t~WWH$x)1M=6nP~6vK3Ku7G{-DB>7HCN>8eSN- z`LI@Kk-1S1qEA-E-SVkWQX_Hp)*y}{vCbn9|P*eC!0-Ybp3BBS7k zMJyiEv>q4yK>W8?TR^b_zBxNNZQ5+xToJ3|n-!y^Dx(Z4@)(ol=(Yj)2+%2#6O-A3 zNpxT{XV?&RA!+TJ#(GU!yQPUj&0|p){I*lq*oe|p1Elxp#^PMKSrm~TAEB1yn?Rn4 zZ74GtUC1f4qQJ69FuU;Ljab0aZcC!xsF+Du$)X7>q3V+fE8!tfqQY)Z!b&iv*u%)v zbYYN>M#OyiOPtb*b^nSR$!4&~T=qap+ebq0yBw-Fq6wp%$0y=0M@Z_uorAGR^YK-l zW|aaqjOk+v<0&TaH2NIVb*qRj!#nN?vH^OCzl^^A=r*Mq@83Br7dLb+;FK12*UKiW z`SbtujfUsX+^@1z^$}QdEvg-F2Qi?U%PMq{3|E7)2Cm%?-H*^IV>N} zIiuVxusIH^6XrNpJ`4Y)ghtQ^>a|zzDm*9_6>(NMk8MjG+xB0(Ce6I# zyC=JRNe$=mq2R{gh`QpUTn&}YXguNzgATW(RlASYKb7N5i06`EAajw&`l5VLo!Ps` z(>+M@5addqJehUKbB8DZ^AvqJ8ozRYk}$4s+b( z^!QxHnH#t|@T=Auo*awzoY3uWDp2mawFPMwq)u{C{%nm|(_a10YrEW?jgc+}Zl$-T zaZQ7gy){_AT`sLrRpnXu#_EUCUUn*zB2rmvN5}e`=y;PaT!4)5H7w+L*>0K&*zW&s zOJ8*+c$Tg{d|?4kRc2QVRhkpn-t4{q|Mb4B*C8HEK_enSqv{0d&bx)h%687|Bejqq zpG?G(L20*q^PxQMdgeB&e&;9lL#T>bgJMynxUIgYVpoo(tt?jMxP4=JSm1cqMeq6zB=uAtgvZI z_lb~Y(A~1C%(u(evCG}i9B3|&wzP($sfMt>#noKckU~+9ZE& z@A3mn6)uooAFteZB(uJ8?HwI~+F;GGXl;RhV6$Lz0Ph*6Ke#1s%i`Y%!ekkcfc;V{ z3bN!CS{_~AtGF2Y3B^7C6-7zA{-K9od6waO)8RMB34m{T-Jy-t7fq6#drU{$Nx?!=?pRz5RiG*|DS%LiI@k5K{ zf#<-67(|ksN4^I!n`{)CqCZK*g($gRcutr$XZEydKb`%-hd+JmgAeAs^=%-+Cy=8= zk)rWb+!w!d@oU1?#!ZPL$n`+d&*%ZsnAcelDQGBgdt>f|2X&5tBG)GS*7{tErWhZY zUUzH!R((T*KVaOqb#Lt+3nQ$N;wij+20Ekxzi0_tqSh#sMQJ%gF;)l?f`JCV;f=zd zHT+~JBYf}&eL-joLRk>35Ehr!W%1w#oDws~0P|I4N%yArw(Q$Z*K9B*epmM1lW*d_ z4Su6iqrf4rZr`@8wY{sewW_qVrm8}cZ=6(7!?tyHsoTmK_OoYLP}a%oI5-0gRVU9< zW^v86+Z&o&2F_|7i@I}bW?M68l-M1Vyxnk`cfu@NX5REXX*_*rg)<{ddQ3OUeU^iA)c^oy8 z{J($7h>fTa3}?a z5ChonGp^XMVa4*j>z;o?iaSaAK`(DZ1y-9WR!8nNI=;T^fccpF{fvhtu0i)0SYhz0O)y>tFA*)#ju(CQRl^YjwG^Douki;14+Kofe1nK*@SytHdg0OK&|tMk@5t<~0AZ)L1T-?Z+KX=i&Z zOi{CdHvlPst~JNZ`@06a8c#I85lT5zpfn0uoL&oSXCqh}+S_b38L6Uqn|Zr&`?gGH zvy^FVOST8}J*J16R~c75R99@0mIee$#8cutn@C4cWD>~}5hovU`fb?0bq}Bn!=MaM zH;x?$LALw&I_ZrzGCDL!ry!17r zeFfudR!MKJnK~7moY$P63KaY`=g-sMQ_m~cph4lykRaZ^z2>q$QE?~^uB^|zcb@^( z#&d*q>oeV(k-e53T=(Y8`gMkBvu96Z`JlfNM59{wo13|go$399+G3c$)>2t0%C`V8 z{tEv~=-z)UToiy*Mv*6qej3bi$KjPZWd-&5D>VXx!-I|8yTn5)fmEh(LJ1A9QYz2M zcM603l&ZOF)LZ?;RvDojnp*BffDqIaB6*}@r2gO6q@}v^H*uZ2G6xQ6O}c80xuQUn zzo&c;q?^j?@+4trA-82^{o?eZ=9#bFKT`LiQCg zWzlngqoe&#G%58e>WS24B>BXI`yP4ZzWZKyql7kH>JvR@c7RihfLRP36CC4?Ln!-% zR%i>Bkl6ro%|;<5CR3suX4NNJu$3XHGzb}zM`WDJCq`6wAOO&p8LvnzGLRsm4aC@j zv=C)iF`f6l3tmc*t7D%?SwH-x>wT~an3xipP!vl6)=q)LfH0$D)DAwgir|MKnV zS!a&jZ4MDv;Fk0?l*OnVpwuw(bnL?+^+6vO`UFG#&}Gj-@@bSu6hA|Lg=YAsi}|Sk zB#y-GTXU3WXsO1>JjOrs)MY@J=@+MGK}Vc&=?iPHC>m(9)uzoyk`yUa-3V|}2X%;? z-Nw(PI-`6$f8ud23AI|aQE$-!C?l7tDQRU6AR)BEf15w~1eegE{{zjFMJ#VYji0*E z{rcA=w+6xqmCap0XD-m7>a*Di6yP|#A_t_^(NO~gV311;2?oibV*CCHZ1kwOY2-&y z3#Gzo@Y5_|?whzOTYUj9h}(a4Rlzsf55c#MjAVhk`1c@LAiRMde6e5Z+e~Z>cvh^- zxxb!7x^NJY(^hoW=K5cKUsWB=m7yZ19qH%kwz$T}_bYb@zr+!wT$0<=1RWUl>goq$ z)n4;K?5*iQxD_|Fws2WC<@A{_YdUH5!YczH? zb+o&r$}^9joY$eJvVls2U#Auu{6SeLn3k_!zucrZdu*0^Lj^boqvDy4lP5dQ=-Z>F zj!L5+v22?e6@#}ag3xG7*GI&KZL6Mp)$r<}=U!?P8zW-{2h)omoHf-j^@p>5UMzOF zI+{8eaf9XJBH`ofboFMNIV4W)xbNJb41ap<-1!bM6Ux}?4MUTZZ?GVaD1U=*#KpW) z4RNQo>dhfD_A7N0a6moQl92dz>kog}`i?#m!s=mEZs09`OjvZ2i>Roppb!ZohZwD& zr`#ngNxBql3IM~zE4gt#`(+51lr13|zqr%W5XmURPEAlgD3U)w9j7V@aQqV<$wJox zUTr)ipHkIVY@^Ax7Ud$!#HpOk;WV4U`Kv)F7#b50ip9E&Gh>S*J$b$kX!$y8ZjUmg z77h(!nnL#W%>k7Fl!|Uiisl%fH9qpCS%gi*fWYg%!1h8=xQJsMid^bMqo8turqz`Xc~@^3=3i}Pd}y-mi(#!xsTL8rn` z%AwwRt4Hsut@YLzt7}71C@AcO7!zZj3?&lbI5MfZ%l3RY=Vx4Xdt_&K;~PUcU73Ds zyd+khli1u>FAQAB~V ztB8CRaKa`yEMoL|sb8s-6e>7Yo%~cZ_JyAdwRITmEz6(Iv7{T?;Pt25{3)w`_eS}D z$^R>rYHLVa5r0oxp^R(++53&`5&x#&g;Ev)c7zI50?dhMvn?)`uT>>z7QF;=^GG@Y z{_c;U9>%;8m`+sN&AgE@064JE_X|~-@c4RCVhI9VN)yL~lNT$sBR*nS0kjDbuFb+iM^$!bZjv^fNf#0NbnTHJI>#-XNqs z&>_dM5~~}i){-{RfoeS_bt;-*817~Ku*ySB^;3aiP*r^;z9#=_pqVSTQEPlt9um5` z7*@GCS6#1aYjZ0qe)U~W`O?dRvP4-zU}vw_Fjs{&{WQyFwM1U>-CuL^SCoyy@+DiJ zAZ$3j^v?dKmZUX~DBQpR*9DI8QiJl+FNDgpxw+G@|3J&0X0bEbmq{BEwdoSE*lPA0 zo%VW^b2?(aOf0Dng*tnV7?1ATx?xRz&4xOu%D&lbHMkP4j7!|4;$Uw?_&qZ&p=;I> zx*orqC3Go;%W(VS5~;xS!@E({Bri$mQs#G5DMU-?X3L@&X`#|~LFj#l17GfFLf4Lj z?yPJ=_o^&isx_~o2giDnx(d!w)(QLINM!3chCwFy3337Sg+BB3A5k_73txhaa|4(n zU&0I#O?zyHb>sn|f5PRF7Odlu1$U_k+q39uGiPwjkwQ2gOiGYhdQe^@Fj*52f4kg< zr4*}5iN^q23x&f>*5r=2Ir(eV6wai`$8)m?XAy6awT7m0P`ph73v7?hd=mc}}xvVk8s#RbC%dKx0Wh$kZ26DpOM zLlKp)=E!fnjnp?>K6@h7=!Q)u`U2H$w(h{T+>}3&NP%_)lnVa(XnoSE5Bl0_lBNFA z+;fv~j+M?41C1`6r5`&BC(${gA2iokO9D72Mdwh*m?3uw%H&r#fX~>3iSdV(A|7*t z|I-x$4=5NNS{faarMtLsv#ZoyRpl;q6|`;HVbhy+%H#*RYKNt!3O47~uJRq?&KZ|d z{hk9$VprF85c;=UTB{Rc<)gw{9?52a=HQhh>S=aJYk8ZuF`SU(8;0c@7I2<~x86pm zReXB5>T`JIx|?r=G5>`Rr^*xLFa7?v@^w@M#)_pDpdw(w5SORigjMyXJl*gR35C$P zivig~ae``V7@QH?avo@@ldG9m!MNMdpNhv6iOT9uN47Y4`UHZ##(JCt@J&~FbjSj*t z=5CJ*=1d-x5lNtK#Y&8Ei0{|{|0gUMUX^$e#1YoyWT>lAgegx72MPz)t|=;7BO*{% zQBzYi{0++F25HSe(SZX40|!KTg08c*wX?Ierb7Id zF5vNkvX$h(h=rUgQvwY#4yU(&f%Hn5OZ(n!Im%n(dgxpHsG76t$m-=U%3svjk`?@@ zO}cayLH@1q$RiEYURczB_PLi|+p}nC{(bd#o+>$$c>3w#;a2(X@KHT`^<^IA?wSP) zs7VN2&Nw8H@F$Z5!$sw1PfCk8=@a(MK*)s^#*U&; z$A#{)tmJ1YbXSPbMIj5_(ZdnG7LSl!67X;sm?+7`(_MYn9Se_$cRdP^2Q1t4n9CBL?VNb*Z zzDo~%9Z+eIuoNRPrtl_&t;LQG%rt5(wuo>V)v~B`uwrEgI4Q~On3y*No3l`!lCP6D zT@ad@R7T5OQ)9v!rP!(s8lg~KZdufZ}n- zH#LPq(T0#Wx2-DVZE$ndhQ4)uC!5yV;35{xW*A zzx?I|Zr15(ceRQN6_~;8@wtsl28F}C{8c$;Z7GdM&D-Uxflc0QF`Mg#qxLGxw`?9KVkR`)0XyS`sy!d5d z-?|%aV700sM{>;6W5X!qeVTfdSa%*b#t!r%!qvLqOxi!cIFunuk zPheK16jTS~LS8(0>{O%Jq$9(YkXfQh*Wzh*G+L)lMIDbDY(DGMq~bn}!RN>k?gr{e zv{n}8f&7i8DM%A^(PA?`9|8peaDabNd#THcA8BTiaRd?p2$i8oozdD-CP3?U7q6c? zR>P=m)2v11$qmA@&%S-fk|nSzsptI_L}%*6{4L$k5cg-b%m=m6AF^r~rcZ$AE*&BQ zh@%y`fLeyRzTi#C?#x&Bp)}z$?~(oT_XS`H&6%nOi~&LSNI#iHViD0Y;&3?z2U z>0ITXKul86$)A0rv*PZ%v~jaon0uFE{MPNK?|<*T)33h!$Gc?XT&V+)xdr)yKg#;^ z51#PjtI@QJPDq`B+{mH)ijS0u#>o%NRIUw5%C&(%ul~_c!-5gx@bK`+FiMq%Km`UM zI6&VA3G_{n>(vDXDyP^)FdeY?1o|n=9H!Z*g{~y|^Kz~54drIkN~WrjZ^`3ZQf-Yk z=^OIR_H<2y-NFWn59zP0O2ue50MEp9p>RukZ@pBzhx5c;QD?Y5Zt|O5H$R>O78a(1 zMI(reAn{qj4&XW`ePV{?H4NSSTKd=Z1c`ZwbN7vFtLS z*`IZBZvquC1VAjbGgbnV9IHHlQAdR}s=ZSHEvu#)KeLTccOevR z)*dxr>)JZ|5vBSmkjNF~UGg9&ew;Ii>`wOX&ugyXYWYj4=UYnX&bhcVzBaqXC*QdR zcP4V;y97U&+T8}S^2jtLv()4E`#oaJ>x|kVsb#Z3hb!jQzVu&(T7JVHxn^fwx(30F z(QxQ4eqiPe4UP^jNOO@IF?et+V9?C(o6Obo_M>ZdFAeG!hMst8p*m%sRf605|nU@SG5LhTOwuAk=y|}byQ*cwTxYs|R-|O4ax7WD6tEMyo8-C0p zzH3m}%-0QZKZ1McGlBS{u`1W<3|S0+$+2h2d1D%zJb81a@rM zU|7Fp>w1U;+&LMERe9n9Zs?p2g>k?)(O^D(ah;%)C{LL!vGO)wt~|H)2IU6MP0Z`= zs599Ri|Qc~^;9P9?5YmtDsx-@O}>GPMEyxu#1*rFQk+?DH^Qa(e}>aPsPqfxz`Kd} zoRLQbfNao*@O_14oT&g1gWBn?Af|~1E@H%tM2H8 zrz9p{*@_nz%RRz?F6m7;oz!_^Z3$u zH-04m*LLFTk`^Bko_Lu*@i3>nJR%IwoQdZnk8p_MyIKR0`A7*YX?RG0Dg;eIj2I(t z1kJ&g!YPw=vW4+ivw3Z_s7mR-%7e>yAc`VS>)?saRBYzg$oF&Y?%KvG<3nR(`xU-V z$z4&uVXfn-T*M}9J2tl!!Y{9^^pxsXJNh>sF!pxS8!fz2ep5a^!l*v7t;(oAs8!j* zj|aqz&niA=&$!AEM6(tU*8!!XReCn5asj1c$K{wWfcb<`Wuq#Gt^{l%c|;xH=LU`l zr88(Daxp2WDJtelR-gn`g~6RzgbH`%OLvXUQg85Nc(CYM5#v5tzHjXjiPFgcEp@9i zAY8sIR_PO=wB-wgCKbjA;6a*TjE&N>2okY%A5c?#KX`H!c|^LMycdw!5^(^Yx~mOT3LP`Q_LrOY9#>$YV%zf+qP-2G&8-lV(rs4Z&tx@@_X>s{8cIqAxM zf7z|eIGf93j#@j`=R|E8Z!-G+(VUyquf#r2O8TAd{bfm4CKOHOcI=HNJsDdx_tvBD zA5BKX8CQ}8jV7Yd?L0j=Qn;XVX407mvaqu41dz-Kd7syz9xdifL9SGOP?xYqoKErn z`?9oMmJF;!)ko63*%}hh0e*m{^lh{nI@wdN^Cu18SRSDicb%4wvK{ zWnLu>esP)Za1WP^W;{_ynR?Aumwsi*;gXaqWXAi_dXaz`bpOLo-m!GwqMy#ox*b0~ zv+u=YhPNL+em@AGS)E1JzGdC)m+~6H$yfpP>DtOzxv{*`X{nX$E=vR0QWAltWL)3U z-JcpT4)j}VyQP!^6R2IUI9>v$65G4;z=`(fjL#phDm)-19RcihI?aAZOy66W*Sx~G zBCoEvP;xk-0>G%*RCwDbDgVqx%%6rpmRE}c%94DK@^xU>)^7#+aQ*hdY6+b&4!-dR zSd7LamHuqBqKX32OmsXub?0|7#WMss)GmT*bqv;WsY18p;Hr~QCN0`gw%e(nkMJou z@))^l0!XtU7M$jH$98mQ2J|Om2M;}K=%gSVdH}@xItOppl_hGrdkymONyRl~Wp?ph z5HzzJ<3n7}*Y$HTCLyZmdihK&0bx^uDw1_rmmpPC<%Mye?+d!7O`J0v^WepFH0|O+ z!23j#MkPF{nS2EW`T+ajJMy>-h$ONbAyD=K3mmcKglgThwofNv8ZA%q`!{hwg(3~{ z-AkAxehp6vDwN{>e(rc4Y5Op$e71-)nWBW9!tcWa_ZZx_7b-_T%U5}dLvO^ z#2AYP;j^_gwU|I9AN4^sC>jh!q*OfI=+5XJJ*}x!ERyl0b8lUFWhVLY$wC?LNxPEv zq%C2KTEhBn6p4pfT34*rd3^z|v9iKmS1Wm;{p0m)PUjS+*4J7J-E|E$aTAn;>!WfGOf-z}lx^=H*r=>54v_!+<*4FqoU$?#u!F)sw zqkbsx8kM@IKuGQhLr5snKw*Fs62Kn)nbK18CTnRTS2ifu32q;xdV`qW!oH||TdF^^ zO~38_u+?q0cyoK;_M3NcO#Iakl)VHTeZX(L>~BBxsevLk7GzN83spm7I_0s)q-Il~ zw7yP_d0<_z+5A46w8*-AnRStEar+bd3iSy`#A-1-rA9`hyg8n6G#i^U@eIh*Rj!z4 zbm<6Ky~Y0CwgZO^@6LbqHZa#&go@|*wfmZycW>OiyQyyNT5hqf<9aCG;-fx9-?jm? z%UXtj9^Lo~5ADYWBb7Q6&7L#}iG!>jAIuzeI|7F^PcfZ#tTtI3Edn(NYW?Faz?7Jp zi`^v+WyU8~?K@_dQpj%EjApCV9F&re9$fKkonC1gvkor|vbrzTB9zU#A7_i|RGN)D z-e!s70;)M7R$8p|6oWt?9(B2f;qhXmQPf$2%KNyZC>EXFBiAc7%ytwZ0VSp}M+L@a z1B?GG8?}Im@`EkH_!>*b0(fCECNwpmKoc$%fryWznvW_(r1NAzvHOEcmiZIXTwTm= z4O@&JSEzv}$!O(s)QEZ))4u(PrwH)TVH!)UH|TWwNhWkX>{r9S2; zufUH59hJE@q*F407etatsjI8WG=?^$TYx(QIy0+j@rgf zc@A(auzxA2P4Ql(q)%uZ;=%zW$&9kdN5WoDKx)e4Tp^dkN$TqkcO3K}@gKl~fxQWf zYBVJ$&xYC)+XiD!OWP~|ggE@vjtew>0?CHZI#~xZ;54hweTVE*BmP9PqjFFHG$LML z-IS<#C7{N@z5zBe6$^Gf3}D92F;nH^pk7RDmf!f^?aJp@Hs|*gN*gv1MHL`hDqd{Q zo}7}MgLQ|E&pgw%cb~L(U)wX!7yv<|pf`f@{JY9i2y{)MKw`iPjWmBG6w;q~eR_hk zd(ueV$)Eo{7Y;}K*ahIH*Q*ZGjMb!kc$WC{US(3(ab2i_<>r;1wgZxab6N-<_=xJg*pf$)SCS+Y0KD-c3r8dwz$aP zLSPNU*{^d`0O!{;g#$Z#_a=ay;@2m379TJ|DenfMy|r$8iJhL*6U=q@6g9X@Abafh zcymh=bz9p}3EJTG7^-TTxAw$CKK$>`jg|~FM0=Q0Y%sUS(Y(E?l|y16YH1KCAdPl=7Ca)@WCk!47YFzNZ8ZYw`Wh^<_%(=k~5@}C!wq4X^e&lA&H=I%Oaj&$g6oA zqMKk^QkascN3!lT!r%yux=W+{fT5}4oIwZ0IHXaGpc)Wm;7U;u&;)yhU=aO6haiD7 zc8uCfkx=|3YfCYM!xe@GkRvKo27FFzjEEgG1O`xLm&oQ5)O~b@sGjjzH5@yJN3Z&+ z7<%(z4X)?ErOf9bjo@(@AK__AWj5hn{*Ur}4zdg2-+zH0#Uar1N(d;R^odF|sPu`c z!Xq<lr@KRlDmqfhj<6t$RpJ|li)}@!TX&J5D&v&0jKx`_POD=srz2x@9KSR z$@|8=yOV7@q^3lu-3;Zf`hd-;H&>QAiW$lE*wg+A&Mq^D0Z7(g`lNc=rTYKh_;gRX zguHrHTH??pkGN{g-GBB|Jm1Ax^8Xg*o#9;2&ZD{kC@MO473RR;D-V~1Dca#n`V+x; zNUz*H#vF29%X$Ot%+lZZva0EOR+~gwJ{s6cY2fR+t?i|~UU9(NyA537t8}RRv?s-N zq=ADKdmPU&66MNQh386j4j0s;rRP85BFQF?)v#cj4mo6ek;kTRE?a###lWK2V&c(oNh-;W8dKaQq$(pG)QWj8(UHnN z2xdJR2kUX=g9i?*fvcQsh(fosAqfGP)dvqAJvwuDV(d9pTJwO7Wf~3%nzW1sCtc8x`hyi`M=?=lRtvjNHHX`lq9Ns(@aye8X{2% zqF-YHi1DBNKj|z}2BBL6&NU(r`v>@u7Kg!lE%gl#}Xk zqj(89n2+mfn(6^3`yqw^(y^3>u2h#BibY}0q+}WrA|54fA(7drjHf09ax>CP;60i2jqwNHEWnf zZ_kzQk@t=U*VNHT{%qjSQ@Ap6F~pl^~aYUKXxfmHeJsp|4f)NMYDD_ z{=@g)d+)X^qX_}q;`%ASABVB{jr^)`+ikbKmn|h=m4&~bqE)~FNUT;NX#Ndu@~O;& z>tP-&$WxUr(5NUfV!tpa0OmfvF-?O|#G`LW)jup{s3MXR&}=r_Y-V%P2A~>1ySAiR zk{|tKP3j{^8!PwBdIx>vo_A&eL_|+X6mJhH26+Ow(5_Mgh|RL$6;638F0|HMr3MfZ zpW}&Nl@WPFBbwBTEnrPf5`J=zVtD8ks2hFSc2OE<3UBi9I4U?DPKPuXEM8D@0}P%@ zP!odE$Ag3MIH&ZfgKzGzP^=rpF!lCAjTncM-fwXapT#(dB_%o{SSCSVsfYU22qq+& zs2I~<)`?}l7P_~57T+dcuHqL|H0=4~9xjTT;vD%649}NOgH|&J{k9l@p%Ey|g|SXU zwmFc98`3_cFhoz%1#xJgt0LBrK5VrF>WxtO^jIZ}+wLMIHkaLF)q89{-Cn>r5L|Xa z-VQJK{hM-%=vk`-)mA~fpjD!3t00#arB(?8g^73+gtN?(n~BzjCP3nZ#-HkTE_Hk-wV;Dsd^ zwo2BB9in7Ncc(o~dQX$DwZm{o*AZ+DH;LhryV_7P!8bgVE;{b&fEL?28BQjLb^{Vd!&AF1;q zNdHl(&Wr)RudI8^ma?+VV$lrX01<1RHK6P70k1Ts%{0l3h7V8SvBjPNAH+4(z=w66E zol3i&egr>)q9b6aYDckGLnCk`NO+ST-(12PwPkwm!1$#AJd+k$WusKl*xKZ?dIR z^fWj7T8u5t!Em!w+u3XDGrs=(^RMr#-Bkh(zyi2`(`LW*!Ou>;dbUlX0H%1)3y=Jy zrzg?WA;zl1)m3V#{C8p5(aU9H4-|{Q3^(bDZPcr8M=NDpC)-+d@Ri%1>;yfv^_#y@ zzGPIs^v&-m*IA`%Wj$BwYp>X5yf~BJ*50|zCsoVqxsU((CBf3#>FP2b*xlaOA#Lv1 zU3I|N)ft62SFAcxRl%NaQtiUuRr|*u}c`g8mxGUG1%^ z(n|NUpN2G~h>Dl4A+bJls^id5*D3u|`(N68+SuG23^hp@Od!N!>;c~ed)Sq4bd|TH zCi$DjhIZ5LNLQj=QW-3%S}WoSy8{x&7AA1L-{%KQbQYsRz)LSQzkOn=F_URZ5?PI( z?WFU(?7UzYGU0k}Fysp({)})Q#m)^VI|wEMdVuSY`^cUV###^*BUmq@D77*YL7B&p z1*=eGDkvuWfYbQ&G~U>0VFX8mV#puHUcKAz1ym(_0wc(2wq?wzdVta?;lSc*8oUNv z)c&9pc4IPe<3t4d*olmYNfGH-xVx`ypFYl_RPwb5>v)`DscmidmSb+cU-y|u z*Tuq%vxQ9=bH4|vHxMb9H*GRIJPnq(C%506*%Wr98^ZD2On-kS9&SiG!nw)|%41i) z#8qdgFaHu8k;x{fWN)iZaHg0I6h4ih;E9Xr;w{ll#w&w}{jMFBUdie(J5ahhU2(Aa z74wNdAU#JD%Cq`qSD#QmdFos{ER3IiN?fJZ3UOc)*V3Qdy-(xUShvwuyGev<0?*Y9 z?6vMUf@9-a0S6VXs(CE&aOUPW?)t6yW&3ljhj#WCUqs>kk zJ0(})g}oiycV-T5z|+*P(aYzS2%l~3M08-BI^YdwXZ)@T^~d?cgC})^Ac_=EF3>;{ zdBY19C^zcl&n4ggwfqIO))=XbQFg1WMmM869!FnpK~oWQ3GqI7;S9P2Pm7R1=aB2m z2F2KR7=~mgkFf0s9gZHaB8TKPD}{OUG@o;GpXzhsBQblWsqW-S_y_c}YPPWK<_QF2 z26RoPq{%JL-ozz!&2`CompE+=NY{xqPk9(n3@Q`J5;Pa0s$nt^RV>;FoD2EzFvqE= z8M1{Vs2pO&Qd!@gG{dkR6-9&s7(JfhgRuQH68a@zy>Qt4l1aJl_YEfbIxgwA$E_^- z@c~e`1fRJip?MCmxC>pnretHIM_LK@t<_bRt~UDk>N-n}OR8DSby=EAw;Gh$Y7$aX zrpv34KSWg>BE~iKAXf*!s)Rb9Yg9HbwOpc{YFt3Yau(5`l*-NR)0Tc2f24XKL*Hyb68ARdPpH`C1-AjO0mo1ZmT*6-IsMr8 z49Xmc6e7_Ygg(l@6JFPx>J7vgv_)IE|irpXA-?Qr; z<30Bl{9pBw*_*OtjPmw8^<4C3ipQ~Y5wQWJoi)$C_k=oAX+v$3FPNiD_}9B%4ZR!K z+qSE(l(^h*LuHy;x=i(u_Zyc)@$B zb$7$gjX9lMD39?lXM)@}-@5JD=L{pm!=pQf>nfANe$8NVIJd(%I$TkD=WTa=^9trY z5GlHD3|?L&4H>XKINGM|vL^NJSuHYEHvBEj)92wr1qqi0T;e5Gn9F z6=WSNB4*=9zB~6Y`8jkmxfi5nQn|HykQ@-Cb7|prR5H8&th_a>J``v}?Kq_hO>AF68eB`C`IzXfRl@{;*#dng;Bie!)8PS-bPCx4rNq#uxH%RydK$o2 zJSuCKu0&<+m1R2)&^7_C{eAosi33NTGW>M$(c7sc_A@6q@-fF!;CXoJEnQy5e0fJsM@q?K+Rp)w5e)<36eM*4?LhRnkH${ zQ-Ui`-UXdU3z@1ZAdXGH(}kFh(0TQzEkoHxBdk zQ=8e?-5zoV|`%$|b+yKCzjbt-vcm(qHaJF4tqUq;v=_m z&uJ0WipIh&r!Yr*<~^Pc6;@U6*F+;|B)~>->VwNEF4Wi@@r3xMmgI7UW9%p}E~Gh% z9=I@&vbX7bTu66BJS_oH7fXNc6NdZrdYA}k7CQpM^#I2_Tq@FrOU zDWrW63llwe@*b$VIRV(=w|5m}IOenqmujW&1t9V3w7Xc3Jdp1Zp;bej0!A%8OgMrb z5spmu2(@sIR{qKBJR3=PExC_-Ez&!Zz51K4YnriK{2B zgq#zFt90@L`Su%MRhLSoik9xTSLO?3ERmKdJhuXQpZtcBzLYm@C@f% zBcFb0$*QGc;h|6k2$0g}wFBMT`h&u7sDDd^0dG3*DZEQXh!`3p$Xab(ratEt`rW;` zZ3a?^!1hBvM3J@>2@Tk?3sH@W#o?(*SHpeTIUD;K?D28M>^?(pptrqQob!9N&*#k1 zH(L@lUB)GN^yyn4<*zF#X)t!^a^uoYerZuTa$&;`zsJ3HncnFN+o0rM3_408>Jk^e z?1|X@9#?@pg6djI!10927&ieP~E7B}5DT(sTaz?2>Ro>blyNnqEGbtkf z4`|+J)h&gm`64JGFe(?g)kGkpF@opBxDdy{8l!Z0Y>w=4X`H}zLgX2veDXZ;)G?87sK2HpEv4QBb0QPh=YZ}{UHPGAK5le{L!_>56iQ}J&a3!hnIzj2g*Rl1xDhJ)dV zq05wM5Pmdm=4p^%!Zo|%=Cm;Y>nj98-`_fFKeLujb?dp)jx7+BokhqHAw)t&Uh+h7 zU<&`S-8>shi`quylu&Ba=*Ns$VlmM`hnOj4w*9R=jnc+*c!dcIVi5B}z|y5$yIdV@ z8*C|}giE&07LD<_9N21L!_EIp)=ww}@QhYo3;YZlGc9*Q9dlw8T4N8^L#*dy5ePZ* zS>gQS8Q+9v<{gimxZH1?J;l~NqxGM7`zMcx>7*^!Xp|q(G}`JcOxk7QIvlTlokD>K zdEN(-k;}+0o@=F{3i6HmX&3DSwkv<`9INb8QEI42YH zN5L*5)J3H_5!%7FDJ6qpk`4uye=+uS0v_lHg(OPET4je*2m=JaN{@{JSDyCX*v*tI zO1d;kAt|K!6Aa4$LWyIEl84+y#pwissmK-(vVrCoPiB1POB1ji^eTwKo-h|Z`2*vf zi`Fl$5{q0*ix(JfdFtMmAa1K6GTf6Z4i$$)2ev$Uo93ZaI}Uop$K3lzA2lA>S-CnU z+S+1{gi*l@?MK$_eXzZE`@XIbukJb5!^L+QT!Y%}gLNAt;>y^Db*m=RAPY5ZIo6$yaXVUV%L@X=AmZxE7c^<;+YXWQ}cv z`3enWMUg#+VJGd9{x^TeHAjlLsNL5LqPw#>=uFfMRqbl(MKNW+-8xs+=q=V{ebxvk zKAY@L!;YIHEwiVZ{0>FifQ0sxL`i0iusDLkiInFf0#4))MKnr%%dF-+nmP`vo;69u zht%c*O;v_CacGnb4F@YZyo3!Dv1m5nBeb5?74O4$7*mDc;1N#s8R*pu)BqJ#8_@(2 zZ!#AB6t&IsIlW?^rf7`Yg3pN*$l1t$sOmq96G4;+5U(I#2tE^pbK~*_(&q@8{fF#L z^paX8?&j0YiDs`*?XI!aH(Q-PQ^FQ?C4I4QM*_slkS^W^kij4so#%>C`h;$`Dn=QE z7-jq;VwCQ!w%+D$MU2wr^nFx}QvT0(d0WEdb6T70?KQ4yU9&gQoCf9+jR(bWcdRqf z5stMcolzahR$4%rYZJrOiQ4wMsI|kFNJOH+q&sHqaCW-8b#b2q8Cy~)$5R;ys`!wv z5F(tCFFc>ND|-d`>+|{LYxW+d4EV!)*DPPYX3cW8V9kG~Cr4qD&^ID`ZU=-$eEh5;Wh^##;!=+PsD9uUea3X}rC-^u9XL;!c<| z#&|3oZxaus_YWO5Ld$tEA5TTv!|l3s*p@M)){M*H6<1nTZG6zUadWDxUfc~XAY-SJ zjv(>@uhiO7Nd6SIZR4u?_cVeq^(@CChLZ#WnQ^Dno5GZhWfV-cj#W&b=v>J`e9RlO>SeGyA{ccxL&{$Zs@PhRi^J6{(=9lic0gA zx`rwpN>P(gQ-Pf1siHxv2#La!Wt+~t%`v>wFm~**7hX8Fq=?cViC^*fijm^t6{U+e zMk?EDgmiV>nr%|*9)Og>E_C^ReoIZHy-}>lHSF4BU<3`YB{m{Ws!_0II^8*AI35Tf zQ!_x15&&V%)vEKgSp8mO$qJrP!ph>c?KQo+jYB5X^q`lzXK>T{n(oG}echeCzr5wA zbML;tZ1EPs(d4PCHJAcb?PWp`4~UFZudB@$(b)!E{ey;W;od~IaD3n=`+jbG@AVtM zQwCX*b(I4y0(ftn)@na>vi&4O>9@+;EuEtiY1=dwOKv7 z9vT2J!6O0TRF>;b=F(e@nY7!U5J%!%d$8TsY$#mAx7%aP6`ZuRXoAlHR9`m+vrI8J zTEtD7M;n*#y5D&J^7Ts_@oCCWEn=2q4a6E0%MqW+CsqP|k1>RXC56)ESZ+W3btQ8T zYhXTDk`JDU6|YR@coJ&PaC&Nx1$6QaZ&Bbk*jx@#lMTak8Q@F*5tTJjTT_sKhq}Z0{ccjjLMlI3|Dp)8Zl|R$Y6lBB z`C`rW$9UIB1k_+KA{#+e)3|^c)YwgIMQq9km z|Il+Wl}c6(3m)G2N+bVe2D%%M%M$Rd!WK=YGtslvu)jKIN(!s{9mB2IE&$_u1of%-&sN4>6!>^t-iQUjP3^ceBC5eA~S0K5R4p9}i8l`n4-3RlhT#+;< z8UX60Gb5B9dnO_Ua4SeG4(4ed7Gw#2Fh_{wAU|Hnk5aY(J`-q1&X966@Cy1Cb`q>h zwXOxjXX%1Kn-QH;oC)NsK2A|6HdqM7g-xrtQd77coEZSD^OO)ENTzHXKt2RS6~)ua zK&AHHAZ_4#NlLM?vxXEHYdafz#H&JlcSlEex2dDP-ejt;?=W?XZ_YU9EBW@bzH8`c z>K3njmxg&Z3vJ>ii>0Y4ZOP))bH2i<=X`hNxnE6u?%eOLl7FnQFPl>qMyLeURJ?LV zCe@zKs9#x9%^3_&dTzaRC_4Mn9NYRpu!K3CRb8^ zbX6o2J#3`*acQC@y zaqPtIZCu=EkJ*ga?#aUxWn`#Lj-i<-avI*k*@AJ1i6fbi@i_8o5px*P{u6naXMlPC z2r(Z>$%{~_SajvI(&I}NJpFgb{c$_n#EuOd`EQsNg;R0xlynR4LfRIBToI&jxpbXP z2SzyM?-8fp=5pP4pI#AKFW;f}N+|wtzIs;Q{U+bl(Akwg+2yyoT)Kvau0~F|kjsT! zu!yd)tDymzU&sZEup38pU7ZbG$o}&uLFGgvqAuO9FvTRv6lGHD+Ws=X05;VQf*NmYxlqqCdun$^e01^4&(|%QV)s zClVK=FG%-snV2oxWL&0sS(?jbBB%>!l)tO^nbL_I-)wcD4)kLATU@gzjsmFhXgC%W zlS4oACpU7AAnHsS+mW7%L!@tUDTstL8@E6Lqwg1eKj4f%KwT887;}Z*uN3)>J|I6&)UIE0U=Pfw)sCVLHOzx)7lXnsyE!V=%#@K9k61mip7NSG)OLsp z|8IUxO+dOAq9J=%^MU}5BgCd?`tc#v^$f`D0#N%R1)T5dE9J= zM4=yg9V%N+E}O+N5R0_OWT0H%qeYJ8@QHGvrm-p28ae<*BtN?8;EJ-)-h zFQqz?y6Hojn$BgzW&s3w{(@1&04hK@f5D8$VHCu{OTUVj-n@#-y0eLJHq?n(7S_q! zP_cBI57Lm1V1YSgvEp4^ti5Q!w4d`*xG~@i>YYI(ZyT=H+K?Zk$Y6cKZFV9>$DM6T zG7K^UBwA05`FF&CTC*95xe`cG5ug8}D$dh!)X6FUA3Rd?AlF2z6rwAGHXp zG>;8FvFj(sSH_mz)+;*NVpy5d$#mK+o~pdzneP~HxwCxnYRW0iH$Yk3^YK|guBcx_ z`EC0(ODc=k-(mb=(UBKwL`a*2?M92$VhM}a4!(ZR?~E@#x8w1Bl*y_Ld?t>71((q_ z)YW7@R+GQ0fU|6bu%XA=;%i1y$B}2mQq9q$s3KRYAz`>gJlGZ&piP815OMkJPOt9x zVSdpuZbWl<&EDlUyU%MEAg&2n7gr=0k0O_jqCgJZ)B#VIu^XO3T=9I zTw-dfe;bGSgc^428L-2t%~QNM+=XGubvPIb^Gfg=U@7DKk!(D{+yGMv(}!9tU{8dt zG=;O+ZPAj6ubtsndTXn;xO-Nv^we(N?CmKvmfJ0EvuO9a9d?5$V@pSbNH&`2+}2h% zGSZgYwk=e5;6Nz1&Da^Wxw0sJn@MyS;_jHeO$eLq>2jl7Hq5gF!*%=VzwE#cG2^18 zEbfXq+5}h79dfqmN~FR*8<%vaBH=_R>29}%XLr9JGr8BUrSd4Po1fWSpI{w$)qE;qzIG~_R_1;v`B<#_!eqoQx z*U1wsN-%vQ(v>jpq?xZm5h}l@v*R5xki~)6+Lhf_Ce+#*krVfJ{;aaLhX-5PlZ zE~8l7dc`7B^NjPCVJn?g%J7kRvMu3ZTZ$J;uZuuDO19DYzkWx=Erva(PWz3LWe>lw z&SrIcf^8nbcJr#zBC}f8Cq>FdNM!2roak^UE4Xrv4O2M8Ok%4KrHTzKw#_c(35spjB4`!XF%>D(v~ zl`IQ4Z?3AS?5-JbiA_zYNsd5FfxXdHU&nfE_14k)XN}K}cJ0H~fMk$5iwQ<32wnO=qp`PNuiXy^K?7c4{7p{}h6uS3=cGByMd z(Rp>P$LO;-t&-v)fl zVlWePm}oxEiP>$?AG=zCnW^#xyFAf==<>w~-Xc&%jd>q3W+kUb3&99oLCYzf`>EQt z2B83Vp~5hwgahzRPrH#`u4bw432P`_T}Z+Qb=pPu9`Ft^MxFROZhL%UiMpyNivW`7 z0mB1qC$%Kj)Sa3qi>C~32%UZS)rqg_C%$m&S&dw(kzSHt=Gr2NnHciDhU@>$<#%dn zGhs11g}($BPeH5H&4f^}zvsHMi=YUF3hsFKBq~p87YL~#iaoDKGw@V;VBjgsCP5LL z{98*MEs(eHE4Qw!cDQZPV4@&-c-?kqq%D;Q6dVq99Sp^gN9HK#Tyew`Z-wHRt6<%R z!y<_NQFjKB4EE?PI;0h8y;{3R2!wvfY49&=wRb z!c|?S{`#GIl;4S(k)7G2o> zD2(Yqd6!sBkaj=Apgp({Y=3L~K3`SEIh`#Kd`;auHGZE?QeCR3OX%OEmNKTX0{;S5&@(`cgsmn@r_;KbkLtH=awJgLrE@$dUt7KI%RJOi-+W|NMDt| z%1N&gT!_`;HI4K|`9e-I$tTsM^X7J{sY1olMI6=KJ{dx#f}XmGuYUMdHLYA;BwfSb z^3qCh4iFQv5QioP6@xah^D*uiv(x|P>W+KlJCKbCe#;N16zN{kvW4Oph~!G{rm!$^ z-VfRlEHRW{Dvnuk2}H4uX**LL*@zGYM1i&9YIb3^j6DGx(x>T6kf=W*!B=pO_C~*> zS-wENLT|Ap8ak1)b=PH_#hxJf|IY){1x`;ZGt|64ug&5-yLi_-oOJJHJpMENO3H-_ z%9cNW8JB1dG&w9#SMYj$x<}^4ngb?>rGO=(zo}6!BA26fUH;lVg=}g|Cs1UWI4uN5 zk7>hozi99}~ z0Hs|G713q(jqJuyJ|QrTRkpxdJb7qw@mWHeU6VJS12~A#TPjgK8Ol+Vn7}%j&9nMc z%&Eqn_6|i06gCJP7xq)0Z9qS%KgqhhZZMfB%QA%`E$;_THTrRGbw0BKMqrzF(_1mlS1}Tcr|!*PX;(&_U$@V{t5KA^@xl zx%QMl*PN1XmTo?KLhWA1s^YY?`0G+Fv{LJPBsc%(Z_1_q|N1WuxyoVWh<5i5lpOZ^ zOP6mVJ4oz-OqLR)V=1F}kL1SDa?hZunTR1hKkvWRj|wPG>bGO6Seh*L(gOK5J?SGD z24?f}PH88nL_?IA#*@W*Q$@>$&5SiQJe2D05e}?mNVVe8Dg6J5`M`+Ib7y&AjK^*c z&Opzz(wF?XyE$8kq8Qm!wgYSc$;D;d=7h;;GTY2<@k-gnnOv!+tnts1mk(+V%3rz4 ze^B}g^G$*7@(g>+Pe7$&Ig6^vcm9!wB98Q2rSATOWu#6D^YWLp_LLjRM66_^A2!Y1$Mvo# z^o|U8x!0fnIg6$3=bsmUcWWWhEb>gOEdy@sd~TD5X%|yI@)=}s0dJ-WVMd5xJ~x0V zgPdiQa}j5=bTwvwT#4l)9!^G`;scMMq9&DLLb9L2E9sZZt28!`)fo{{@jc{;`a_{d z9??gm?xfwgNh%-EygrL%>7XltBS5@`*T$hV#6r&_*sOR}f{?$cb;WCL3X!eT3HXEbbkOqpFuvJX`GODN_ zkFmTKKxxfBpYgBm1bAIg-8lT4H)!he!k+3OhyMW7|!>T`VKzsEIjQQ z1RyBmR!LyR_Q%4wbQj`w&MD%rU2xmA`9fExAiFNgtuq#tj2=BYN`FcgEh;Hlq^#7q z5+F6=M5^zc5(1b#Ln8v0%jM=$30W}U&!TbC?=mQy!PaCJsX;SM?Fz2Jp03FmrLtMm&Il@J5N1oUOyvxM?MB%d zHadgeIq}NI6>L{Mu1+q;(@^k~;;~b=%E{$*Y*yu;j$*X*a8fxTf-v7Geu^*Xa)1}> zr1BiT0C@c2e1s{eLd)ofa5MqdYFG(C`k8P@LNy6N&64>sMui|W;s5NDTv)MUx4Sc#m`yw@ki(ANY@f z?{oJn(Fs~Zv1AG--{L;INp8+rcw~Sl_@)rPAV`*czdZp;}7!{FG7i1)>v+LI)RQNG=92rz>87f8w zO(EkDQ@9GGml5K?V1&^|hmUw)`M|aOO)#S?9`cAw-TO-(HA+GGS}K~1lH}N{Rs$}Y znnf+prcAn{QQ?{6;SlX<3}`LO-z$IdQM%V;(6D@3nz#Hd%|ojz9&(FIJ^OI4Qs{Z0 zk~I!8!`wp^GIRrO|)AC5I_G31}+h* zQMLG=kj7Y(=rZg?3IUUKrIm+)VvL$H@oj{2Af;k$6|FzIW$_QXmL=Eku^rK!YW&HG zH;i|ycF-?>3Hf`BnecVL)Vm^N2i>jx+BKKS7xakjw>`4tdGnuZ-g>e9U0o`OQeH-R zei8qsPrJtAx8x$+kGr%l|CSG+hJf4X_MnuUNEA>w-X4;C)Zt$)r@^EJu{Y=u0`4G* zLwcYd25w~t>^fs86bOby!0Ev@NQ*&UEUXKJ0|+;FXhGp^4GBJQAiWR-^8`IXU7(l; zB=0jog`R}UNWQFvA?07@U{OZ?`T3jTB8bqPc$5cJL9o7L`Wm->l^j>I()8jT(fJ zf?fg7OY;3FSAfn!CEwPNF%%92)XqX*;s1C{2>GMVHzdDFzuTN0We-iHz0jN_o&Mrj2&4mZL3GoUjN zgVCLOg%2Qf=0-d6;|I76%E(~+pk85q`~*B;)6)1s1sFDd&?RAGcyj!pYz)RvAgn{2 zBR_u7DXnb$pk4xvpW+!F#B_WD3L4HJD;;#&DQTF&ZuyG*lR#MhzY= zAysGiNAj)G_c%wi#qV&+x60qsJDIZco}$_*(4+?$FL`po$Z3>j{rC?`vKd8lL`bj5 z@gn}u+S9Lc@~wp+3(b;l)t;WueWOqnhya*`Y~oK}_enk>H!BL(j3xuGObK}~i;S$2 zCOG~7LQ>;*Xc~HL>2@I$3IaxuzD+$=jWWwr+#S0k@^}A=X8FHpx)QzZ7L=lK2VKVJ z_VB28;0LrawDIJtsx+9Q2cWy^N(BKec<>N|HDd)!Mou22SX zT$J1(N`nVo9!U!Ozi{ZUPLwmzI zBxpbfm5c|AAYY-N)l7wPDYG|-#45;3wUdI4()Xv?A$Luo+tQb(86jobOJ2X>z4u;! z{XGG83+b%FZo&0uFkqH|8#1!M7-w;EqzmV%j4*+j#d~@NeT8}>jE=*cXeHA;e~#3G z@vBxTA%sG?28sdFv2<~;j*GBdup7XhrvK6<;d`+gs8^%+J`+yed5>W5$0#`Hf8}17 zK+Osd;QVdQ!41R%<_1?AqP?P|m%l9I4-7`H+hu6Tm|FIWuJ75)T-r@^z5tl7$GH$A=-n6N=dSI}={77~zy1M1 z;&=1W=2%O@s%x<}ALcqb_&SZVmmAmkotrjn7Om}$c6UOD>clswY_>}@qoYmFbag#_ zU#8>Ur@DGF+oJsi{XO$~YHC+%w!rYa2Yx1nkhilDctV=s-%=dXLI$#nS7@=<1bxD> zMI0~(e>i;fsGfHHHiLZaI{pfB5E+A*Eb6y3&}$6_#iPeix!n&}ei4pk@~OMwOI)G2 zuO!!w5&V`GI|)=-7arvRM)=+CMT_(>v3y>GbQQ`-EL^C10mtw)misp2TWm6ri%1UU z6&fZU%M8~4Pd55-%)z{D7dhDk9w5nP);Zrrp%Q2*k?!zYP$uw7jTFRZ6@xu*ibk#c zpFCo^cEJWNcq}3I$>m$L)4z}eZT4t#M>riIiCq{PdITKQaF%s*_o09lChVsM!j5cvzG5$gK~rOG>7-?Jxs)f!>ji# z7l>9?S=~F>6N~N{6+_{eANsj_N@C7hLuGYMCFEl;zh`1Z0Ue}8j?G8_t+XOyzK$YZ zLkd56z=fbfw)v*SpG$-M@#9jTyp`){@>iLfnoL#xrVe?l)TfsgNdL?qzbk9p^rh`X9oc?=)>I&`LkTuGIsXh_%+!Q)vOs5>uwZ1fLqm+SPu?KOxnBO-ytkRk z)V$X&Cp?FNJ>!tq2e*M_^~HA?r-5-9suVm#kepJ8-_sQL_^ozN!P=&#H6Dl6?}-&O z?Ol^~wE0u9P{H18b`OVi7k|i}U8|QLpMm4O{ubrL=3id$~3i z$Iq^z7QR05Zc>FnJ)q}#r2 z*T^>GE`ME9n|Ph2tZ1!a-S&-pc0D!tUvZ&N!;&vZ{(G}#m$&}d9Z=|ARc{hoOciV0 zZk^L@hc=hp)!7>pqrJU*<7t!-O=brTht_8rYU-PtEkcXU-XYn@3cn}sg7NT zQsTYN>LxSGDf^67m83Mp5E+@|QoITzJdxBD5l zi-bV#36)}DYzP&lJqZoe*&?k%B;<07HY!dEzyND4;4hL31u8QPEE5To!$}AumXvGj-g_b0}&*wU_-8oaXuFlj{SKrZ; zgVyK*D2EbKr(|BkT4>Rl853c@^f@jaNhdN$(X&}1;(l-dh>2h}wlF*QL1~P?>sdep z#3+()A`>uQr^kycdhB))Kn`jok^Yum-4gQ>u6k|KlXS!c`D%GCBA;g@N+z5+t5DKo zw;C4q0ydaA28_h9GUZH%e&^@;Cci8;dA+H8KCSDv{{-^678zNP)zi{E~y zN`T+%4+V7Z^uPFr_YHs2c3Lw{QQ=y-3P}JIk4H8p18qpGN~x!!8yW&RN{PJ35ut}J zKfWMLh(+rumhaGQ+&EM@WqL}kiPdPtGA@5>`Ew^O{{0r=_kAb+D18ohg(M;Fie@UU zEU1xl{6i1ZO~}4g#B_%?zIqb&Em_ZiXFwRxU3ry*u>cqp_t9I-KJl&JR|~)2dg4!K zKIR4)`4H?*Wz{97EL(9I%Z;i?6I4swHz;5+Y)LVl-bQ9KGMe|UQL>xCN*1O2i+I~_ zZhquMPU%mN@in#9#F-TWJyu6D}f>Cyfs@zP6)-{^W6 z^h>*nSgVom2SfbY1<#*4^*sGqaP74V7F^3F-u!t zhs*VmOh+(lq{cS+OrYoPsEBulyW4UFJ(_a)UwQcr=?yjMMJ)z1xT&VnT;^U=@K?KN zZ+K^Bpm(qm369e;&E(sBtdDjkhr9bkX^p&wtKU*#En^FA9AzA(&)*hvC!)SYL8bJ+ z-uqQF;!W8C1@g?~Qt6AHI26b21xw{G$}_=O^|nM^1@B!`DgQ6dXG7L~So-`6Y$I&J zLdz;;mXs)^GskCv_cekUt043cqGw1w%<7qwrIzvu1?Q}YWso^$c>ee-CTDtj7W)J@ zoJW05u*pgkS{eS!m}O^}^`~IAFb8JS0&#{yY>#2Ie2m{JBXmbUv6!vu(qdqf+;8Cw zD!|1;%PrzXY^A4=i#S*Lo)`-1_r$q?8M5U#Z|<1RM79rYRA0c)envbQsN*T(560o!4IQ=xwQPfYma_O5}gy|Jh>ZS~$# zw|IenUUz?g&-RS&szZG9j@rS>oFjrVXf|E8Jh5&y)n)+C9!z^zxG%o7J*P{_c4UpQ zNF5p4;-54vhd1mi?LmPZt)R*2v{E&|rX8<+E7J5oeuz zf?}+&^2`y%4OfM{nNyy}mgJeGEYhiZ8}dwe=j44-FTqzpNl_N9q}UEV(kJ-#N_b-C z2px;bPp&7@518;#-q)W$i#V$cVIaV;K|4~yJ$JhBPVHzZ*VUjo=acl3>dU{Rp(7yS zG>hH{qDMuAr?ry)!++&Jym8{j{1OinAm$j@q?b>rR?sQUz$O?@*t+wX!g4!OyS8uO zHf+~WA$aw#r{(=5%TI>k*l7a#&{vQOYTacuQC=2uuKd!LLRavjOilK}e zw=AcyYzF;|43x=|MJ%mvu9{6W50NOf9PYT1=7Y(*CpT1Q!BSXpQ7QodWiZWF$P7=u_kW9h|PmO_G!#tCgS z45k>?K6-HGAse`i}UmSuV{!V zu&zdatcYhqF>CfMCpVg0Xt8bvm4d!cERx^70DF6H}r1`sRQkCU~ik8ay8et?n zynEDeuwqwfL^v(4;kz1BwVMn@qhs3bWxcD?tP@V4Bez=zntFt`cs!Ue9^Q-VlNb%< zr5KST-QFGD+GmijdyNmL+C%N^fM##A8^DlO(ja~)|DVWCV7f{SgDo1GW zrj`CFNi+CVEZ*9h5Ndik3bq(Y`$nW35W~FJ&LSBT$5{V>bOG<$)|jjA&^=>+t!lZU zREwM(k4L!vbz*^`Wb@-}|524~nMX#T6y}3mhB24#q&Zw zFI0IS+*(cbz$#H3^C6xU_}W^{3T{>606`Muhk$pmLkEYHLs|JR?BPy+*O0^7C-&Mh zHCqf8B>|riJ+U>mYqw>3dlN#kZz#445l}Z(iC|_z-fR%JR`UU;BVaey)q5O`q6wuk z8j(f|dnGBH+^OjdXJaUC_0raJ`S;^LC5V`cX3PGJbn$6I0XMLlPS6LUEduu=jI8o1keCJK?-ieY~pVV7Qe>T0! zCzTLV2?}=-1uoOfd7NIS-9U*C1Ov0V_y=k8Kcszf(-U~5c$Jld%4;2@g|H^aA3b(# z;lf3Wj&c*?D8F#-8ULbj#=<;-m6NJROd4S*yfSHuL`j)Fn~ZGHH|t<;U_xZ$Yi12n&&&fL{Wtrmd8k_r5d=2!TbfP^9fr z`4UU}mOlT1zP^Gtf7{o$-`}^TK>ECVfwIIFWFF=_09Tq!o2oYTxWl^Z-;n-%u~ zEqz!1R$tw>%D<%sg5b2~u7s%c#eYI(p0%yTxUiUO38Ke^KYdGaHwzwqSk3O7E#1Yd zH^^ijevnhH2JBQ}Oq36)NT_1rR-mCX~+6iFD(PZT3-KI(}ZJJRV6QTl>5YiTz@+uq^y zx?G}Mg!qhniLzKwq=Gz^OJF=w=X@%t?=ILzgb9oNB?JyT?b8JI38xg+j#Abe65hSj7U@UUc?R@4iUI2D#Hp-v)CEnAfmSkm(D=_sUnU1<#+M0IH;_;TD_9#tVIHROz zv{rKovIy$#Jm{}`4v9R07kR2SNeZDaJ@xd9RFzVE9BBe>H#iKUb^G0Z>jn;t5X6L0 z`W*>R52Gqn3}rPy>5sY{_$+^kbF{hbb_HTV66rbD8-PD&I<5S zTD?eDzK(+X48cGg4d)aw&=v>>=*jLi*mIgt=L{sQMydN48d54A3*|%)vu?iKNCSWbv*s89bYkvjlaLE$0cEu zUNm4-V$Q<*X5~B5ogbNo((VLbg_)C&e{7=}g_%QPsco2PR3Vy{o+_GAm}!U%igHGJ zlP}*XUGNwR?s1Fd3ku42W`A<;(|0tYWZhO!Ehwo!SEpYre~v2hNf$icT|Z4b$sX6q z>!fvR;h$sI@c;ewJDTo&I{Om_KVJa{h+4zeg}?e55U#=G7od z&dOn!XfHjQpE?&QZb|&om@2)=W!{; zd9WCRIY0)_Q6!Rp`dq6uom95MJtf!$@`Fg?HkR2a>p=5Ko)JsP^j(um-Jl(cLHa*15UD$;s_=_XKlRc} z_dazCNMR*#BN4`cudTR|2x9;`u11*{;HWU#fr|ngCD-Q+eCTtIB{urGS1p^dd^Hqb z5&NF5`Z@;J3B*}O%Vmm14E7+@qBN(;hc|>esBh36RSZNu5bU%M3Qu2jQDNal3}GN? zm%4;zTDF8}1C^dQ_mnO`@bfsf7_wf984w}JLpjz`<-!C!LsnfOaM^O2qiSY?P-lf^ zC2n8G5EQ~@gmEqP6*FT!6V3cLUGuK`ra}I?mXH+4S286i6_+X9t@)3iy!;Iwn)S$6ASV!KgV^5GYFk`+*GQpLbVbsS5TV`GH@FOWf3 z^j;vEkbmx+2I3o0u#X;+AL2a9A#EBIFoT!_*>mJ65uYIz4I*PX=1xM5Sd|H+73s?u z8sooGMdhw|O9>_vTr+p>I7m0Bq2&HKR*nCDmnD7Oyz3CLAQXF8F? z!i*To$pl#t*$*gyX`vcawM;02iK=3Al^V~Aihgp)k`}W68Rwl~AiG7>v`t!vtvl;8 z^_iyjrnISFXU(~Kx(&wM^Z8H*=d#aC>1Ju%OC&4SynsfumP zK>v1qPh*z_o`5}4*S+09YoPL)kldxDk;%b@ad@g%15J^i0{d$eX+MW)HegDa= z-c!`RcqY{)S#@TYR#3cg3Rd0lDS z^5y#HwH31#XqO*OCpse8wt`zq`NPY(1)8>Oq$82mHQc~IuUWp_mUeY`vjxAonr~_| zMJ$QKhxH4z9lts^n%;2@|Gf6_a*NI6YVsDmIKnSK%ssF1Vl3G#y5+C)hYu$#5mQ@J z!2;&w4PkxX2O2S@S>F8&(k#J{^W1X}QHZT^<370@2{^!*qT=y;kY)*5Ug();)HKW0 zz_@5UeIkmPlmEiAsK-I+`+P&a&FZjwOa(qb+%`ig)L8-v=#PQKoQ%h}_3IPyaN3*k zcC@scL)Fnbi=$rW@Ht(;=D@5;rP{>!_WrFq!o%V1rtYe~^^Uc*wV%Gv=O7YnZb@ap zJi?zAGZAaWG3jdFRBd)T%pm4_Tb(+4tHWV4wj#SFkqn4|Xb`2h6J6cOWQQ)>?%c5+ zW6oiBiEf9@Yj3lLJt&{3>uT>w4;Z)iSQ?XJ+!U!a*D>YDPwW`Akp1*}f@0liCGfpd zTFw3dx2*`64-tXjgZ8kGtEGsc)_GZ7O#)R_R!vz(?6B9{PK0tI(?T zI$9l8!-xig|7}sgpqK}sm#I#8#N}2quB}f_Yw{~8;BX6mM_saN7IfDqjaMt zgKFh5@w9Xa2U1OkqrFhNMBnbT#7ry=kxlHK8tFCpHBQ>8>JXl?o~0zz)j--8Nkh4c`d8!wyu+1&6}CgHwShM;w{WPRMHtxBj7Q` z5TWY7WJ=0+q1Nx;j6X@=&nbLivb^&}Az^uwHVJg6Su~y%zolVxV{!_NGDxfVr|;$9 zoDz>(rS^_%pMDxSUcpG%@R~9pRCNn+o)*0SkVlZd3{5ts$p@n~PkvPpUF7H8#w#Qz zCf4h3h8Bu~oSHlAP&kRl3#9#K2tVf&27q?j9gyffD6Ig%mx&%Q!65D-wuwO8UUv9^|6vs#;|!rdYxajYSn%w%)lhD=IaZf#>8ahRPdm)usy8)R>pkM#^3x>dgt{Uipu$MlC0%|{m2y&8 z98k1Qv6_$-L+xX^EklhvodxoP+9~a&Ze_`mo4(6G7)K?LWH42bJr(Nh?dWwEY;|vf zATc#=H?0&Gv|ar@`E!Of*!@~uQSq6(RgrKI&#-;{RtAhwKIFI7^BOfjW#xf8jKJRZvgw=*=ma0A-l7+ zTGQFM)wZ=&w+wRf#3Y>*(^MUlIHR@*s;(1k+J-pvgSF%ALO4w=&y7K%Cf z2R^s9-d5|X_7#k3JA#=|I-*0lkgpty^11Vt_%Ar&}QEUbnW}*5lermo^0TSB*+H@on>IfD{S1sW8^jJDg*@pIly0s}-?1`2F`OsQ^ZO&Hjs`XVDjB0`% zBuS`~UjH8NGkMG|i*8R{qC3(P+*(kgS%6OJS=*V;cC>dD;G`BSA~^-}N(e>P?P;)> zOqPZM9G*(TgDEJN9sJt5bVG-!%TkcrgN%u!Ema_WRay(%3|{hlZA-`oFnX-$teJ$k z_N$7&OhfJ%NV9z-Tqx>~dm>v#^{8GPau^qDoDPr6D{d}9c+ZcKvaM9_Y(tR?2L-+XJ+9!DOSFmGa8v}hjcy*%N4?5z zaTMH-&82i(d&sq|MDGkm=(ctIV#F`bZWC@h)uxZVtsEHUx8i5P*yC=&BnTC0$d&^sim>c$R)iC?51bB=f`p@WC7OuO z6?Pg~%)-TT(}A!voMc#e?Td1w8o8wd8thS~ODKFQAo*PuANmS(oOYjcwNKwFWVEz; zMab^-%NMKBHN~7@7K&m{C~(9(m+C1e)BEXQ?i0jl+M;w_pGLY^zL+hjaGz;o7o|!y z4tM<7r;Oiv{eDoRCK#Ay)myXcHcr<^?-)dYMlw?S6eZo)VVx?8Tuua}ZDaqEQNFLI*9@7wLVe-IE+ct4~}222A& z%apou2AL~+mqSOtm{B$bW_}@09#Z3g2NmlJXc?oU!sEhKuYd5eg{1vKXwTDc9(>>L zM+RIO*hSJc(MxOr(lrYO|1AALc$PF^z6=?J{yip9dR4QwX?4{@ES&T|Feq36nMtFm zA?mrPyU8>#i9`?NX$&?OKp1c)=kY}-HA2#zfWjDfS5kz6*U0TJ9A}B6SPv=3K$^!s zp+o7@I4dVoBg~#9&m5IzD%v4WAWS{`RnDAj@SCl(=j*KwXS2nCxW9rZLdFX!Uuslz zo;K}lw?aGdV)$zEA|?AR=%bAIKx@I7`5i5(rf@^S!=)ezmz2&`CG(}D3VCh?2yBe_ zi`OwW%KT-KxTN{07;#A{ln%N6dH5BSUzJ@-N8nq`R_K4o#TP*+9~dt}woPd>QUx+u zWZo%=KYP(cDkO@MGL}Tf$z|h-r4)^$z!6EnDZzS>O$EFjVlhy-g)CZ_bmvpoV5)9( zh;ji+fmKB7lx(FlFdi9gr>miwiC;DVN|oNmW1}<@iXw#(*RR~+4!c9{U_qP98FUz{ z>S{NOCSqn{&ftlZJi>g^1jJq_@lAdlF~Uik851ktV{^5qQ6z{0iGbi~!YN1|O6rc& z_t-1*-?J&De4n?y&$`YvSjS+@CB?u{bFZnxlJTShX|Nseghs2~ZWCKKrK&p{b?q%} z=4OL6PL}_y1WtfsK4ifeI!nfzN*j^^yd$FxXS9l&k~l+XZwb*EF$A(`f1QK3vwL7* z_pVKY;O14Xr}AaQa7G_dHYH6gaEXL)+LIlg26FQ$uXnL7E#q; zpu!%DD<-~w<0^TU*M@YHXu$_>ynlVnm5h251*@cgfBzK@B5i3@V&OF_Q7PSMmD zLiqH?#h$+WNKLz$x?)!**%1+^ z*K^NH=Sn-}-?Py~YX6~WeJB#H4-9(-B(aXYyeU4wmT*PS&93O|I z$=owM(D9>Zche&6+_`LTn+aF>|K`0}`)N2$e;6kH@-n?OMY(0Fk3ZGJyu`0K`(X}h zBSDB!3$MTa`VH7jgUDjWEukXt0{^CBkkl!0{xa>&V;`d>@R7ak{CneyeW*8LX5Jmi zP74Za{8OkEsCQt)U{bG185DE0a%hy7N;F{)w^}aWrwxZV>8cT4{=DXiN1s^A$&{8b zB9)hd-%n3fCh7TU^7yOVip+kg7yotltU60>|081dOhq5E`-&SeG(pv z#(imfRG#&Y-VwI>9d3EnWqP~QXNlUQwsv1UnvCghuF|?)L8OdZ(s^Ds3vj0``j%AI z37Dx{lg&m`DKVYKl+9;!I2>k+-UAV8x3TIaO;XX{PZY@idaHt-((s6nLyni*+!p|w zt23()IGlc$5v<)!_d&6|SX+k%<{7Ga#;S-3cActbu;O7w;uSC)6D}C6eCM6%iCqrJ zLwTMYnKli%_i)tUK>%i(cg9tC#UvQ-5nV`@skL4rH>L zvhz>sP1#L;Q`1Ek=}jhoRY%jw`TA!8W}dw9YZu-4>ih50l$j{Ts!x8U_$G_VGJ4YW zL{%aR6X3^Xn%VF2J=bf9+r#KQZz}%hr3$!#Sbd)K^G5ETDfj*vt=#;#o72(^6F?vU zm{iN|MivdG%ZoYOKM!@j(LXeXOgG}R>O=3#!}IepMSswGh{6hFwCGfl#x86_Yo z&@KTg$=iJ0sOFhvNA4k7noxoo*CYauNx~$*z&s}mh7x30F>T~I!1%03K$^0Cx)G43 z_-qm0)~J^8IR`7(3((p(-bM=|gMvZLxSTZlsIn8*XCZ>g-Hh+^HY_eyIi_$1viC+{!kktr&y3Gv1!$!CWYdvw zEQao+5|8T1D9|-k$cEXbNYAkBFlN@V^gdX_5R!v!%V6*&U^Ql!JT~X|@)~JPQ}(Pl z{m!!KK>yCNvw(h`{MyfWwe>$&o~z*Dg?+=ofaE0_>4b-uUy~?^07#ui4O7d;d@V*2 z3R%#KMk9H^YW;2nI-ooc@uSthsi*?;6DQRX7UAU<1Gl6Y!=59get^!~vFgz=ARp*r z*Rdbzoe%+W7=I}(;uG0OmoMi#w07S^Vcorv2Ohf5uxPa8@vhEDJS{xF1nBWp`sb3z z^w;@VP6AR8C?|oXAh2OPADAg?J1N3pjY8XVC39g>(>fv+ z;SI)TD@I|m<6??#;xpQlSq_&_l>$Pcl#@6L%kdE}hu zL!_{0;q|NLE5rqrSn6>KrFuLC(v|YVJf(rntF`A1sj`K;1v|}bHhGC-!`OId z6yK3JHgJyMj?S1ZU2;agL{kLs@57li_1Sn=B2RNl$e4roNo01I)Z}+Ex3Lzh3&qHY zX)i*UZ?bL@v%TL&Z^H#Z_OA`c$`7B%sr2oDYf7$Jn(fWi`(ayX+n5i_dI5x_6%- zT`FJ7LEJ+LU9sWtX>M91mr;q+DW9Ammnon8lLOcC2~~Mxy5mdku#m$pl1CH(WB-p5 zvxOQcb76>#4{wxV3YZau2T3Ppa)4zXsM#l3j`)#VvcF_VtJunBnT)6gXw(RRDK(y9yu%X5fyQm3e)2x ze+jsV)xs!3$WBSJ0t5m}mt_MHGJ-{LOwrj#_~6t3g1bsH;p#H5ZF<>u#ej2sOoexV zfiwAcF=aqNyA*Q(t)Q!XA}8TSoRSx`r%D-}nRHf^ZkE5nudiQIQ(`Px)4Qu)jC#WH zm{Gbz6ARmcn{R$&vV>$dV?og*pk#EXw=tEA6mwNc;2~TTTP=99ESw~j%}_<$)O;SO z`Vf9~UL%Uh0jb&_qo9>ADOGx%Wyl(YWHG%$-1t{g4?i3k$_*QzqlGWYvL5f&-2cGJ z`(5H4u16O>XZ$H!1d{}$-L6?xzGnm`_~**7N&^GIN{Q)}*PY zbaWacnFR_{Hta|#;?l`DSh|^9E!!PULaboGd*AuH5MBiTRdTgIDLsb#Oj|L(d7;GEtaq_?aJ!Ci&foSmPB3*^EZT+B;Z5O~f z(&HR#A2cYDNhR00cW>v)&Z@mtD_7!=&Xp?3m&+wbM^m{;+o&}+7k{L3nbS#zL;4um zy*Z=Iub?SKx=!fRJ%h$BW|8ShKmO5ag6I8rsp ztX!^cO|~~fV^;hQq%#80#u2yna20hHvt3Br>Y7Z(g0hnJYetI?uN(Pz&!cb&WUt~xdZFB>+gk-7P zFO+#q)uqObrj9<35b-0`4j`?|1&YU_zOo^=HW0Ab1Xm+??TnHS?n4GSpG^9^38BVh zEm?2akSb|6@m{;lhdgjOS5j45w9c?#=5bALy1P5cZ))D!v@@bTgL2-qFkAN_FI0U%58GifYBwp{zl1IlZH zzOirM4TC&7J^9Q+T3HF_^#M(N!rTNIV3($%r@E6WUY$LZop5QIKtI%Cctz7w*-?py zk9#F6s4Spl1(lT*Z3bXe{di3LSZ-iToV%Knj%wyU`rI2^(?h*I!f!rQN#A^g+pxYW zZ-wES_wEGG+R5MTs~Xub$Y1_P<;}kQ3YG(#fAT`G>N|JpDoe}-`RuDZw>Ne6^Uokc zvg7?lqKZkeOY?^i=RfyGU%a=uOMqs}^_tn@F!$0+Fbl-f^dDE~2G@7u>}9TUn+bHE z;vVun_TE;Ut;6uR@ zhBc7_k)|j>oyRkvPLRnmUf5w0xS$pvoKl>NfI1G8U`Rm89bISga0(^_9;|O4uo7l4 z2iWo=bR!egzk_^SRt>^4fNi6s3#vTK+dHG7wqGn$<&_Q9kY9ABYFj4Ib6=1H7&kzD zKjt>0Sqxo7B-cl6M3fyQpqNlv3giK>N-#M5=2F(GLG4C@7?ymTG@urr5uZ^}*@oPJ z$`@E&9nFob8-yDEPO^>;=9!R+hg>XY%^oP>6TC(7RZzmmQqDhHx$<)*eA>!9n3RAL zK7C_x*Iu=BXErwkXh5d9_QIFWXLoK1m5OgpkGcA(GD=RAdvoO;a%6O#ggDGGsI^YeS8r z+E`r^Pc;Z%xXJFcy70Y_Eo2W>MFahv(M|EK$>FGW%ZIVfuBJ|>-p}~ckCYrA+VL|- zrw_4cFrshW5lN-PNsoSuIpvO+-8OyQTB=35y!vYA?b3a!gU(Uwkgd}dD5-LSClo5J z7N^J~F7{EpRPNt(tL8jJkW75pg(RMBc2^WH>W~ z*6w9kQD8Sgo|Z<%`-ihl91Le8go=DNE6#=+JjW`~cc`nLUobqcVX1x1 zYU?uX&9yJz{WIfp`*$2{7h>xEQh9;W_ix@@szA_4^ zjh*+aDNnASjZI-q2qNQLmkZFWGl_+Ka33gM>N<1YzGKes*_(c>5$efedQRYpjlL0{wPDu7C zJI7t(Qhejo&6%VzgB;Vvz!!Ab**AzALQW~uH_pu=@{Vol7s)-M%aVgDc~5i&v&*9B zNdENUmvl&(k2^zjkqPk(24cbo!<=S!j}yw{L7Z6f3vlX`E=$T&R0S_o2c*ln6p_`c z!oobEXy%Y+q;Tu1Jp8h1YvBk#G_y#JUsmPempxmzjtE0%iZu9T&j^0mQ@AycFFG@% z#xHyDHGDsdgZPdTL9m9=BDM77G-^O&!aB&42t`GjcM#RfG-WqlF2@~1q}ii%tx0Bl zLFsH8fW3KmPFwWwhnMRV#f%{DwTA1`yZRp$(xg4w?L zmYd&KBnJ`5;*n@SovsjXeEO+8)_N&xiu^RRH{Z-A0S&3#3P^i1r&rJR#+b7c7=Q#< zuHu$c@1Hs^l-5q4E{Dwe@iR$T)8tcWFPN9uTe)!Bo8~186u)&&rL;z9-^VR{o{~hl z@adn**R2<`rYT|iDck~Z6x3**z~SO??Wdp0SE{GIDI8}fxB?f)orq9Fd$^n*k7<{lYTYj z%E@O~H1q`@>rCO%T;hktvve%5S7YRowd&zr{ zLJ3ta=bT0ais@0wQvBp%qWhbTfHWPF5=el5*>Y`srluiej#>Dv4_X{HyUn3DH+MKXjhnig+gpX2*6xN)##pQ|7!_XJ zvw#1fq1zOx<)vk_xth@I@EL4SYlyBx@J zAT%-~E1#7HSEGKPH*V@`!5Fv}&J4LOk#hz@3Nc9FB z&t<{!zy}p|25c_pO_1fXILs~_Z$3~H^VKKM-g#DfRI47 zRCC~U)qh?o-UiM<1i=#Qw078?kIIaWNdC&nFF<7&bTT`Gz`(0X9wrssk)O(z>pb0nRj|hbG!09S z;f61#+r|Hvd+??M*{Yb`XGXuH(;RdrD+Wrp*Y-G4 zwE??zmZa7-SW{JPM!1!2sm^9pX{U?J>`k=+hlMRsr^I3|r(3#m>8!1RCr=lPPrk1D z{vj^!ITeN*Pa4>5y4ZD^(wz_|uVNy}H(DsPxI+)B zottf&%sso!L#177wbE6mx%Q3CC5iRgThxic=Kl6gTIm+lbv@0#<`zRsusPh!zoqsy z_#5gC^^Fa|2L2v(TORkaI#!z8n66lzF4|nVL;EtY>kq2Cb_{LaoiVo zv#FuqKB#?PZC~F|QnOKeNc;|WrP|l*r$aY32b=jnsDllSbZCEruYr$9mvQNyB$Q3G zFRLrp7L~3xYj+_G4K_E@H~hFJTuc~Ofp7R4JajR*ZMeBpi60S}_5#c)!p~gK)Cu6ddK*gdF@)aS58Wa2i= z^vX;+55pYw5Gg9Lt)81Lw~Ef9%O8+0Up~tDmk%%lJIi!116;rgpBXn$hfo&+M(+Gm zD&AA2yzX3ZhivF3oqLW9>lugr0P9WXrb%glBXNO;_{EMx&C_zPiHsB?v^y<=6Hj6) zOlm?wRwU$=kI);+BZaGWepa2@YH4Nw3zI1{fiIAfn6uD~#P9=0)u`Gpmf7|gvtA-D zE*2lxp=1w$2l-`4=F8X`nfSXK^Vs3C2Aokz_wNAvSOfUY9;i#%#UDyjbS;*yKx>ou z196Hjl?b$26ClFG9@2Mzs7pio1lzH>%^KHB__4mp6sSl4+z+HF@*(sher&OH2U?n# zPdghl(%*CcsXC`tQtooksrKZZ z%b&XN6dlb28y;Gwy7=vA>3-zf(f{nFSLdG}3(>>He7=(w0jj}kDCFl)lD~qNi2=Q= zym$><+`Fitgl!+o1t-uQv)cT}N~QW*xEpsWUN0sRWr9!21w`r@K)IOBBP(Rdlhm7# z_|02y{U!lN4-_DVG`bIc$mUvaxWOuYT`SXEh*wP;8&gx>TzF_28XC~f8h?7*wx{FT zWH6pc8qyZ}(u1}oOKcBnE#4F;0G`BU%95A%>+Zh$I)~Qgx7%z6bIO)($QVLoe64<)OS3<$rlEC0iVOWy+9WWMPmsAO4m@{YP5m61XqY2 z46n&m>8QpRTQ(;LLPPqQeoj7%9b66`VS7Ocv}UaBNJ;tH`h1TbEw*;6!Gg?lkUtpN+p%Lndw6m2x~i4#rFx4$L0lpjS8%%y zx9sTc$M+hltR_!chGzs|?M5Ng5^YH|f#NY?M^u=p;gIE-4lJHxD;Q=$Ho?Rc3byen z>=OWq7`%#XB#Vqtc!v@G^i4=og`sTRR+Vh9@pnIwbyB)?t_o^?PKTimIv)xCqvyr1 z&1O!>m#&92eWxOUN#1e>ktIU_flR{L6^?WUR}kl5o9ttfD@pFDkZpx{3+y^*6R30v zxQ|O1`viyk{CD4$E`n9?Kd~Yz?J=$`e%F-D@KIFBqDbj7Ca9hfPiG2i;vCz5c_2t& z~7tnY(LB=2kPeF3iN5OfZ*>3cRet{J@mV4P39=b%zCiB_IL1%=nXyP+$ z(_c8YG*@zRD~U^0)`_9Qrgls5M`$OI(~-!uK#WHEJL&6kLQ*d+5U>5}_xHCN57I2o zi~;oW#_xO9EV-ae4PIcOp!I>?bSok)@Lw==_jI-Q!e?x9gCF1%*+rltFOCc(ksuxJ`m4rD9Dp#_-ih?ZO3jb0 zciwsx$e`hijj1!rIrx#7#QycpU;H>FR7#J5B2N82l?Y#(cMH^x$WcW)_0Jsj3M@RZ zj8*=qcL0eh_~n#bP^=u3M3t8Wjg-?a{sELv&7`n_n3wDkDQw8u6$=l<YnuTdw*g0@SYPl zFu&kSjlA?H*aPC(WcuvDlk9kEfy+zO&Vx)>Sc-sH9i`e59Q;)Be* zl?G=r-54tTj|WI+re(Zzat0Am05iuL(KCrbn$5IH_T|6yi-@7-un7Z;LRbO96O!}5 zfO{wtVlI&>ri3Pg8pe&?UIGcMlH5;bFSb+~+hf*bSHw9+e)Q zC@!Gx#dsIs4d2M^SWARP=(=TL1#9FISHcNoEPjx7AQnGVrU!tD2mJ17_l~uZC$-K- z#zzqaP(yIOuwwrsF)aXK@HZV%SIo~pcPr=WtLiL^Y3)HfR03RKSIozw*&0pKS{Hx> zAWDwL6Fy=28jgX)Wf(bsgW6(ogT!)UvFd)M3Y$SYvnnj2)*RW}$W~`Tf?{#*=#dP+ z=i=Pl#mupR(r{^ed7quXpK=k*xsumd#kn!PuI@{;_ii?zo1P+I8R3ezVul860o+J- z-k4fEW96!1DtpKkPZ+3O6%m*Gnv-&(%Wn7bRW4PH%L;7?i5PHXSzHF~6mvBl*4COR zf7S1~Zj-57oh|WPS4_1j(bn5^A5=BR16>XA zSqF6u@fvHL0bCO8wq`I}RES7c&r-kqGG6{b%oFy6o#-9X-f_nrKLvV^eg>tcGwgZ! zK^=BuX=Q>mlVc*|50D^B$PFHq)ZC`DgCJ>!N1%)sW#E94$I`}JhW`M!07aHUo-|{U zcXpRdX^pB5Yc70;`E9%;m^j;+q^7^CdvgeUP!!4V(q-z}dK9uvn$6xWC;>sZy@F0H z!`V?}IuEabA``_WCgl1ka3Mx2*7Eg^rZd%spm+=GF$JiHtpP%@rp2DBtuoxDWF8Y- zYoa+F=hLwmQmxP8V3{;3@|=q_^|6MulSd^QVw`pMdb5)^JDteFE~00TI+9XjeOA-r zY&OSuR27Y*4|Sh| z*U1^C=Y{|cF$o)F25Xm_Pcr5hbAaV#2N8>+n?}iCT&&3vP~I8i@x5SbRA-zV-Y?r} zD$|koFlL(MX?0Jux^>G=1;R&aNXzaXW0~%pb2GWN2AUivLAl#d>kK&}Zb7_BdQf%w zOqPOGJfI2NHuE#Jc$4@bI+8;1m_Zs@#O05m)|sED0@sUt$8dHIE3OcEqEgKfXA)1 zn;R+{3>2(`tA+?(GM_^B=i;B*(}FwVpzqq`@3z!~6(*j|rQMbU%4BABgT-!k;}!d+ z$`6$Xun%25K_8P66IS*cT?wX%CgO)K5uYc1Qzf+{sX}o<1}#G14=_4TBXD{KSHh@` zrH-@Q=3l51p?EZ51Vch1?y`r3&9|x)OSo42J!g7L)#0v9R~w~+>grl^wOcUVW6SF= z1AC5iheB4FNkiYE>FxDz_OxlGM_;6%?@rKhTI;Hf4VI=>;inoY>lUuTZ?&0?Rrb!7 zkkEZ%Bw)Tp^-JkrxFQqD2`hDkT!w3c>q=IHAL=Taz&VX#FTk8~AvWxB@hR~-uFhhq ztFu7Mly5qz8o5LL322@fz4}lQc0z$-l*td%*Z88YA^8_TrGPnRD5^K$Yn!dfX1`#5 ze@|o2J*xVUy{d}UH}4XwxZ^KM=;Kh?k~LnJxxOf}X4A%B|3oMLV3^xlzc00I>z+OF zV?~4O2On{)T3t|JUC~k~Es_=i-b8p21>hm#2%=8Xo#(Y_>A2utq!~2@E6&C$Q&~vZ zC4H=_47S&G8(~Dd+grPW!Y=V+RbQy8d!td>qelAtPr%ys@7Oy0Oj}n*oxHtr*As$|Bw+%Z5 zU$X-aFGwEW6^&fk`X}0V8@6iNswoe-r0|BjvJ4MyTXb*(U*GNQk8BBdH+8nQzVJL& zGeU{I@QALpzNso~iWECb>rH$526o2}q~F}|>JvMi5u4xTq%thA(Et7yZ++Ax1e+~Q z4LW6sODm^oZExh4yWw3x&Ezsg58FU7;!IEZgkmhrFBn1`Xm$O^YjS zhsM{f3jxJpZVy^#Vd1dk&I0@o4Uq7I7+@YwkdizCClQ3CKCjMtIUpfj>i48s#gBu! zpljWbQM6E{L6NDnsC!3M;tlH7=6G{Vcvs}%QP|q+jM5wGh5=AW5`Evgi7;1u8_ocg zo2W!h-Y~5WEOYf$nmY49@G7Se71eHJ4&^LjMn4>hL<&1WX~yc4$R#qc2*S#Ym+?0u zURovI#BDCw7uek2-ye9or2onv>7X@rY$7YlybqQYWpp`k6lFJWdNvk4JcP-t^4W;{ zKey>Lyp)B@D9a*eK^87szK|^(39*A)vtwyrPEuPIDX-jUko20hE!ACK{#oaN%H4%A zSK1$o?%sN^``MVbGt}DIX%Mf{Y%1?6bMra2rDbc@YEMpKAlu@8gu#kRB&~AOkpa$| zAnV9e%7mvRJu{LLyv_G$>Qzo|%#h(5w3N6h@FM77#iXY_{t2x6=r$5ZggqAm+eH zXN{3F6i;%7!eJ%mNIA$kgL%W)lnTdVK+Fmh-2_H1EFn+D~o5nB$TC4;T};w)@V!fkgU*7{|FQ;VM|wEhDN(v$M4U zw4?P7S3~6jom8nVt}H1@2*Z+k4KOvO_O9YBMsdG-OXrr&b|GKX?1G+Uf1;|t*myIN z@u2M`7lgr{Mu>V1?=;?P0Ytv^HzlKaZ~%`drD;U2f%w^?n9AH}_SkJ}*61D{TzYW* zi@S%>k{3?<)2?TA-QJp3lkq8ac~w<8Kw?t1_#^3u;saJ~udlPS*RWI5UDs|3j;A5B zAF0THQm8k3O^F(<_!DU;QQsLzw16{J97?o%yX({XT~9vJlWokpe4N3l934j_c*mc` z@LZsLN0&JVA=-b*J71cdV{SyOEY;d@3v0y^q{KAvN&S;3d z{9}=xXER>@qGN7V(jT%oT~DsjNu}c5T)op)S7-Q;E0v>G}+9l+NV~PKN(*WEgEtTI}UH%x35WS+2ri&H4yi2 z3-9<`{%E2Pm&Ie$+{bA)6m2xsTeKz5adGt8#ADibU*C1?kl|+!AD%bw;m2=ZvTNbH z_wzpS39j`(>#_rZHS3#~t{GUfq50sNLDc~kAKVB;>EaAAC1D(C@N?u2xpiBLSx*B6 z4C6m{(L&xNoX{k^30I6?`lSa zUiKS&73 z(b^`=L#1ny5f^Yj#Dpwe74W$<&&S<7|J765QbV16f|xJm ztE$S1tOc|@zHn}y0ysaiSmy=xlhttjN7A>4SMYIbYapI#ZtdFKH2}#TLX`tPzO4LpYF7uGXJA&i62GvEoY-J$a}q8Hw9WFn?q3aP$`CxF0GMl53P zUf3Ns^|K7K+S0tj}dpLA%;LDN4f)AWB0H2wd^X!>`; zKYDx;3wt8^g#8qa}? zPpDU~qV^ROP34NI_TBfChLkz)ktmk~IP=w#3x$YDWy2W?#Dg)aA%n_3Qwo4Hlzh)u z3G5^n(FKT8cc}XlrUoHLQ*Sqw@PBxht0>#Br(zlPR>#5dh!S+1KUlG2FXt6Q0ksl;DSe+q<9ddh2oXT0awN{ka1$Ln9tj7c9=T5?fw=ju zhjQ+<3TB3ulQDJABt~}~F&-Jse(^I5xU3raknME-B znf4ST7XE$W)Vu#`{MS=+W+Vh_W5SWbcT#CTI2tWi{!aQ9i<-X5AkRWEkES!@kSHRo zcm@9ed&w!30#%HER0WnUB{Lv&sT)VZrv)P*qflO9Q3wd-X#t&q@CDEJ{6Whs*$Thj z)j7EEnZH;5m_tO7DOL*=Og`m=PLEuCUGl z%M|HA6SU{*+3$f>gZ`g=_0_XyU(F`L+p+-|$4;?CgPal*q)Vr;Vm`{BRE=UEDWUQB zN3tU>f5e<~LHhdr+^%6DEdw!kIDePU6+@-kFq_?Vl&^W6bE%(Lx#N&qc;5B&!RH_e zK8^xeq`qbTJ+o56y@{8XylITG2;y&)c>p!Vy&O6ZArl<%L~c5zv!tES(F=F9w6=#k zqn)lMv$nOawN;f!rlT#)Gpu)+Z8kP$QxS$u0)-JCf*}8(z`E@_a;6i)chdOBcAKs7S*+WDGIGjk7mN*&D*a6C;%Qg)U?4D_q6c8LkUg zk%6M01 zhvd|xyC=&-I;?m@*_|sbi`l>iu!TqIR~Wx$%s@IGT{llJFiI^-C2cO2N~Urp-pIH- z7+bM)qefO!pEmg;CN4`K5%up7tX=kzvBm10H~{I&0vM(l*GJCmh3k9^!d9`ic(QF% zq;fZ7pO*jLY;23!TwtGewV?i&a3vfeK33^YuFvyUZY*=j;WvSWpBV^3pO*?nB z@=2vb(IThgz)E%cvc(a~AbC1j0$EjDmU_I`3gU%XYE#q$1@mYN#7aAdIx!r4Jq zO6W3oOe!Vxpp@XfcqzeRb-V1sYxk?#6Yc3PBWT`(T(UFP73!7?7$$F}v�`Jl%i( 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⪼z6bT&M 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 literal 0 HcmV?d00001 diff --git a/multimedia/.config/mpv/fonts/uosc_textures.ttf b/multimedia/.config/mpv/fonts/uosc_textures.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e89f1d8cb87262b02db089894e92a52e71f8bf8c GIT binary patch literal 38228 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 x7J+&k#Mt Playlist # script-binding uosc/chapters #! Utils > Chapters # script-binding uosc/open-config-directory #! Utils > Open config directory + diff --git a/multimedia/.config/mpv/scripts/battery.lua b/multimedia/.config/mpv/scripts/battery.lua index f14be30..0477821 100644 --- a/multimedia/.config/mpv/scripts/battery.lua +++ b/multimedia/.config/mpv/scripts/battery.lua @@ -1,5 +1,8 @@ -- If the laptop is on battery, the profile 'lq' will be loaded; otherwise 'hq' is used --- +local mp = require 'mp' + +local SHOULD_ADJUST = false + local lqprofile = "lowquality" local hqprofile = "highquality" @@ -12,15 +15,19 @@ local function powerstate() end local function adjust() + if not SHOULD_ADJUST then return end + local state = powerstate() -- this actually overrides automatically applied profiles -- like 'protocol.http' if state == 0 then - mp.msg.info("Running on battery, setting low-quality options.") mp.set_property("profile", lqprofile) + mp.msg.info("[quality] running battery, setting low-quality options.") + mp.osd_message("[quality] LQ") else - mp.msg.info("Not running on battery, setting high-quality options.") mp.set_property("profile", hqprofile) + mp.msg.info("[quality] running ac, setting high-quality options.") + mp.osd_message("[quality] HQ") end end mp.add_hook("on_load", 1, adjust) diff --git a/multimedia/.config/mpv/scripts/copy_videotime.lua b/multimedia/.config/mpv/scripts/copy_videotime.lua new file mode 100644 index 0000000..9331a56 --- /dev/null +++ b/multimedia/.config/mpv/scripts/copy_videotime.lua @@ -0,0 +1,80 @@ +local mp = require 'mp' +require 'mp.msg' + +-- Copy the current time of the video to clipboard. + +WINDOWS = 2 +UNIX = 3 +KEY_BIND = "y" + +local function platform_type() + local utils = require 'mp.utils' + local workdir = utils.to_string(mp.get_property_native("working-directory")) + if string.find(workdir, "\\") then + return WINDOWS + else + return UNIX + end +end + +local function command_exists(cmd) + local pipe = io.popen("type " .. cmd .. " > /dev/null 2> /dev/null; printf \"$?\"", "r") + if not pipe then return end + local exists = pipe:read() == "0" + pipe:close() + return exists +end + +local function get_clipboard_cmd() + if command_exists("xclip") then + return "xclip -silent -in -selection clipboard" + elseif command_exists("wl-copy") then + return "wl-copy" + elseif command_exists("pbcopy") then + return "pbcopy" + else + mp.msg.error("No supported clipboard command found") + return false + end +end + +local function divmod(a, b) + return a / b, a % b +end + +local function set_clipboard(text) + if platform == WINDOWS then + mp.commandv("run", "powershell", "set-clipboard", text) + return true + elseif (platform == UNIX and clipboard_cmd) then + local pipe = io.popen(clipboard_cmd, "w") + if not pipe then return end + pipe:write(text) + pipe:close() + return true + else + mp.msg.error("Set_clipboard error") + return false + end +end + +local function copyTime() + local time_pos = mp.get_property_number("time-pos") + local minutes, remainder = divmod(time_pos, 60) + local hours, minutes = divmod(minutes, 60) + local seconds = math.floor(remainder) + local milliseconds = math.floor((remainder - seconds) * 1000) + local time = string.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds) + if set_clipboard(time) then + mp.osd_message(string.format("[copytime] %s", time)) + else + mp.osd_message("[copytime] failed") + end +end + + +platform = platform_type() +if platform == UNIX then + clipboard_cmd = get_clipboard_cmd() +end +mp.add_key_binding(KEY_BIND, "copyTime", copyTime) diff --git a/multimedia/.config/mpv/scripts/gallery-dl.lua b/multimedia/.config/mpv/scripts/gallery-dl.lua index d49c561..3cc21db 100644 --- a/multimedia/.config/mpv/scripts/gallery-dl.lua +++ b/multimedia/.config/mpv/scripts/gallery-dl.lua @@ -7,23 +7,24 @@ -- e.g. -- `mpv gallery-dl://https://imgur.com/....` +local mp = require 'mp' local utils = require 'mp.utils' local msg = require 'mp.msg' local function exec(args) - local ret = utils.subprocess({args = args}) + local ret = utils.subprocess({ args = args }) return ret.status, ret.stdout, ret end mp.add_hook("on_load", 15, function() - local url = mp.get_property("stream-open-filename", "") - if (url:find("gdl://") ~= 1) then - msg.debug("not a gdl:// url: " .. url) + local fn = mp.get_property("stream-open-filename", "") + if (fn:find("gdl://") ~= 1) then + msg.debug("not a gdl:// url: " .. fn) return end - local url = string.gsub(url,"gdl://","") + local url = string.gsub(url, "gdl://", "") - local es, urls, result = exec({"gallery-dl", "-g", url}) + local es, urls, result = exec({ "gallery-dl", "-g", url }) if (es < 0) or (urls == nil) or (urls == "") then msg.error("failed to get album list.") end diff --git a/multimedia/.config/mpv/scripts/sponsorblock_minimal.lua b/multimedia/.config/mpv/scripts/sponsorblock_minimal.lua index 1940bc8..907a191 100644 --- a/multimedia/.config/mpv/scripts/sponsorblock_minimal.lua +++ b/multimedia/.config/mpv/scripts/sponsorblock_minimal.lua @@ -5,6 +5,8 @@ -- -- original from https://codeberg.org/jouni/mpv_sponsorblock_minimal -- adapted for local playback skipping and some refactoring by me +local mp = require 'mp' + local options = { API = "https://sponsor.ajay.app/api/skipSegments", @@ -31,10 +33,9 @@ local function getranges() Ranges[k] = v end end - return end -local function skip_ads(name, pos) +local function skip_ads(_, pos) if pos ~= nil then for k, v in pairs(Ranges) do if tonumber(k) <= pos and tonumber(v) > pos then @@ -51,7 +52,6 @@ local function skip_ads(name, pos) end end end - return end local function file_loaded() diff --git a/multimedia/.config/mpv/scripts/thumbfast.lua b/multimedia/.config/mpv/scripts/thumbfast.lua new file mode 100644 index 0000000..e01ef4f --- /dev/null +++ b/multimedia/.config/mpv/scripts/thumbfast.lua @@ -0,0 +1,975 @@ +-- thumbfast.lua +-- +-- High-performance on-the-fly thumbnailer +-- +-- Built for easy integration in third-party UIs. + +local options = { + -- Socket path (leave empty for auto) + socket = "", + + -- Thumbnail path (leave empty for auto) + thumbnail = "", + + -- Maximum thumbnail size in pixels (scaled down to fit) + -- Values are scaled when hidpi is enabled + max_height = 200, + max_width = 200, + + -- Apply tone-mapping, no to disable + tone_mapping = "auto", + + -- Overlay id + overlay_id = 42, + + -- Spawn thumbnailer on file load for faster initial thumbnails + spawn_first = false, + + -- Close thumbnailer process after an inactivity period in seconds, 0 to disable + quit_after_inactivity = 0, + + -- Enable on network playback + network = false, + + -- Enable on audio playback + audio = false, + + -- Enable hardware decoding + hwdec = false, + + -- Windows only: use native Windows API to write to pipe (requires LuaJIT) + direct_io = false, + + -- Custom path to the mpv executable + mpv_path = "mpv" +} + +local mp = require "mp" +mp.utils = require "mp.utils" +mp.options = require "mp.options" +mp.options.read_options(options, "thumbfast") + +local properties = {} +local pre_0_30_0 = mp.command_native_async == nil +local pre_0_33_0 = true + +function subprocess(args, async, callback) + callback = callback or function() + end + + if not pre_0_30_0 then + if async then + return mp.command_native_async({ name = "subprocess", playback_only = true, args = args }, callback) + else + return mp.command_native({ name = "subprocess", playback_only = false, capture_stdout = true, args = args }) + end + else + if async then + return mp.utils.subprocess_detached({ args = args }, callback) + else + return mp.utils.subprocess({ args = args }) + end + end +end + +local winapi = {} +if options.direct_io then + local ffi_loaded, ffi = pcall(require, "ffi") + if ffi_loaded then + winapi = { + ffi = ffi, + C = ffi.C, + bit = require("bit"), + socket_wc = "", + + -- WinAPI constants + CP_UTF8 = 65001, + GENERIC_WRITE = 0x40000000, + OPEN_EXISTING = 3, + FILE_FLAG_WRITE_THROUGH = 0x80000000, + FILE_FLAG_NO_BUFFERING = 0x20000000, + PIPE_NOWAIT = ffi.new("unsigned long[1]", 0x00000001), + + INVALID_HANDLE_VALUE = ffi.cast("void*", -1), + + -- don't care about how many bytes WriteFile wrote, so allocate something to store the result once + _lpNumberOfBytesWritten = ffi.new("unsigned long[1]"), + } + -- cache flags used in run() to avoid bor() call + winapi._createfile_pipe_flags = winapi.bit.bor(winapi.FILE_FLAG_WRITE_THROUGH, winapi.FILE_FLAG_NO_BUFFERING) + + ffi.cdef [[ + void* __stdcall CreateFileW(const wchar_t *lpFileName, unsigned long dwDesiredAccess, unsigned long dwShareMode, void *lpSecurityAttributes, unsigned long dwCreationDisposition, unsigned long dwFlagsAndAttributes, void *hTemplateFile); + bool __stdcall WriteFile(void *hFile, const void *lpBuffer, unsigned long nNumberOfBytesToWrite, unsigned long *lpNumberOfBytesWritten, void *lpOverlapped); + bool __stdcall CloseHandle(void *hObject); + bool __stdcall SetNamedPipeHandleState(void *hNamedPipe, unsigned long *lpMode, unsigned long *lpMaxCollectionCount, unsigned long *lpCollectDataTimeout); + int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); + ]] + + winapi.MultiByteToWideChar = function(MultiByteStr) + if MultiByteStr then + local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, nil, 0) + if utf16_len > 0 then + local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len) + if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then + return utf16_str + end + end + end + return "" + end + else + options.direct_io = false + end +end + +local file = nil +local file_bytes = 0 +local spawned = false +local disabled = false +local force_disabled = false +local spawn_waiting = false +local spawn_working = false +local script_written = false + +local dirty = false + +local x = nil +local y = nil +local last_x = x +local last_y = y + +local last_seek_time = nil + +local effective_w = options.max_width +local effective_h = options.max_height +local real_w = nil +local real_h = nil +local last_real_w = nil +local last_real_h = nil + +local script_name = nil + +local show_thumbnail = false + +local filters_reset = { ["lavfi-crop"] = true, ["crop"] = true } +local filters_runtime = { ["hflip"] = true, ["vflip"] = true } +local filters_all = { ["hflip"] = true, ["vflip"] = true, ["lavfi-crop"] = true, ["crop"] = true } + +local tone_mappings = { + ["none"] = true, + ["clip"] = true, + ["linear"] = true, + ["gamma"] = true, + ["reinhard"] = true, + ["hable"] = true, + ["mobius"] = true +} +local last_tone_mapping = nil + +local last_vf_reset = "" +local last_vf_runtime = "" + +local last_rotate = 0 + +local par = "" +local last_par = "" + +local last_has_vid = 0 +local has_vid = 0 + +local file_timer = nil +local file_check_period = 1 / 60 + +local allow_fast_seek = true + +local client_script = [=[ +#!/usr/bin/env bash +MPV_IPC_FD=0; MPV_IPC_PATH="%s" +trap "kill 0" EXIT +while [[ $# -ne 0 ]]; do case $1 in --mpv-ipc-fd=*) MPV_IPC_FD=${1/--mpv-ipc-fd=/} ;; esac; shift; done +if echo "print-text thumbfast" >&"$MPV_IPC_FD"; then echo -n > "$MPV_IPC_PATH"; tail -f "$MPV_IPC_PATH" >&"$MPV_IPC_FD" & while read -r -u "$MPV_IPC_FD" 2>/dev/null; do :; done; fi +]=] + +local function get_os() + local raw_os_name = "" + + if jit and jit.os and jit.arch then + raw_os_name = jit.os + else + if package.config:sub(1, 1) == "\\" then + -- Windows + local env_OS = os.getenv("OS") + if env_OS then + raw_os_name = env_OS + end + else + raw_os_name = subprocess({ "uname", "-s" }).stdout + end + end + + raw_os_name = (raw_os_name):lower() + + local os_patterns = { + ["windows"] = "windows", + ["linux"] = "linux", + + ["osx"] = "darwin", + ["mac"] = "darwin", + ["darwin"] = "darwin", + + ["^mingw"] = "windows", + ["^cygwin"] = "windows", + + ["bsd$"] = "darwin", + ["sunos"] = "darwin" + } + + -- Default to linux + local str_os_name = "linux" + + for pattern, name in pairs(os_patterns) do + if raw_os_name:match(pattern) then + str_os_name = name + break + end + end + + return str_os_name +end + +local os_name = mp.get_property("platform") or get_os() + +local path_separator = os_name == "windows" and "\\" or "/" + +if options.socket == "" then + if os_name == "windows" then + options.socket = "thumbfast" + else + options.socket = "/tmp/thumbfast" + end +end + +if options.thumbnail == "" then + if os_name == "windows" then + options.thumbnail = os.getenv("TEMP") .. "\\thumbfast.out" + else + options.thumbnail = "/tmp/thumbfast.out" + end +end + +local unique = mp.utils.getpid() + +options.socket = options.socket .. unique +options.thumbnail = options.thumbnail .. unique + +if options.direct_io then + if os_name == "windows" then + winapi.socket_wc = winapi.MultiByteToWideChar("\\\\.\\pipe\\" .. options.socket) + end + + if winapi.socket_wc == "" then + options.direct_io = false + end +end + +local mpv_path = options.mpv_path + +if mpv_path == "mpv" and os_name == "darwin" and unique then + -- TODO: look into ~~osxbundle/ + mpv_path = string.gsub(subprocess({ "ps", "-o", "comm=", "-p", tostring(unique) }).stdout, "[\n\r]", "") + if mpv_path ~= "mpv" then + mpv_path = string.gsub(mpv_path, "/mpv%-bundle$", "/mpv") + local mpv_bin = mp.utils.file_info("/usr/local/mpv") + if mpv_bin and mpv_bin.is_file then + mpv_path = "/usr/local/mpv" + else + local mpv_app = mp.utils.file_info("/Applications/mpv.app/Contents/MacOS/mpv") + if mpv_app and mpv_app.is_file then + mp.msg.warn( + "symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`") + else + mp.msg.warn( + "drag to your Applications folder and symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`") + end + end + end +end + +local function vo_tone_mapping() + local passes = mp.get_property_native("vo-passes") + if passes and passes["fresh"] then + for _, v in pairs(passes["fresh"]) do + for k2, v2 in pairs(v) do + if k2 == "desc" and v2 then + local tone_mapping = string.match(v2, "([0-9a-z.-]+) tone map") + if tone_mapping then + return tone_mapping + end + end + end + end + end +end + +local function vf_string(filters, full) + local vf = "" + local vf_table = properties["vf"] + + if vf_table and #vf_table > 0 then + for i = #vf_table, 1, -1 do + if filters[vf_table[i].name] then + local args = "" + for key, value in pairs(vf_table[i].params) do + if args ~= "" then + args = args .. ":" + end + args = args .. key .. "=" .. value + end + vf = vf .. vf_table[i].name .. "=" .. args .. "," + end + end + end + + if (full and options.tone_mapping ~= "no") or options.tone_mapping == "auto" then + if properties["video-params"] and properties["video-params"]["primaries"] == "bt.2020" then + local tone_mapping = options.tone_mapping + if tone_mapping == "auto" then + tone_mapping = last_tone_mapping or properties["tone-mapping"] + if tone_mapping == "auto" and properties["current-vo"] == "gpu-next" then + tone_mapping = vo_tone_mapping() + end + end + if not tone_mappings[tone_mapping] then + tone_mapping = "hable" + end + last_tone_mapping = tone_mapping + vf = vf .. "zscale=transfer=linear,format=gbrpf32le,tonemap=" .. tone_mapping .. ",zscale=transfer=bt709," + end + end + + if full then + vf = vf .. + "scale=w=" .. + effective_w .. ":h=" .. effective_h .. par .. + ",pad=w=" .. effective_w .. ":h=" .. effective_h .. ":x=-1:y=-1,format=bgra" + end + + return vf +end + +local function calc_dimensions() + local width = properties["video-out-params"] and properties["video-out-params"]["dw"] + local height = properties["video-out-params"] and properties["video-out-params"]["dh"] + if not width or not height then return end + + local scale = properties["display-hidpi-scale"] or 1 + + if width / height > options.max_width / options.max_height then + effective_w = math.floor(options.max_width * scale + 0.5) + effective_h = math.floor(height / width * effective_w + 0.5) + else + effective_h = math.floor(options.max_height * scale + 0.5) + effective_w = math.floor(width / height * effective_h + 0.5) + end + + local v_par = properties["video-out-params"] and properties["video-out-params"]["par"] or 1 + if v_par == 1 then + par = ":force_original_aspect_ratio=decrease" + else + par = "" + end +end + +local info_timer = nil + +local function info(w, h) + local rotate = properties["video-params"] and properties["video-params"]["rotate"] + local image = properties["current-tracks"] and properties["current-tracks"]["video"] and + properties["current-tracks"]["video"]["image"] + local albumart = image and properties["current-tracks"]["video"]["albumart"] + + disabled = (w or 0) == 0 or (h or 0) == 0 or + has_vid == 0 or + (properties["demuxer-via-network"] and not options.network) or + (albumart and not options.audio) or + (image and not albumart) or + force_disabled + + if info_timer then + info_timer:kill() + info_timer = nil + elseif has_vid == 0 or (rotate == nil and not disabled) then + info_timer = mp.add_timeout(0.05, function() info(w, h) end) + end + + local json, _ = mp.utils.format_json({ + width = w, + height = h, + disabled = disabled, + available = true, + socket = options.socket, + thumbnail = options.thumbnail, + overlay_id = options.overlay_id + }) + if pre_0_30_0 then + mp.command_native({ "script-message", "thumbfast-info", json }) + else + mp.command_native_async({ "script-message", "thumbfast-info", json }, function() + end) + end +end + +local function remove_thumbnail_files() + if file then + file:close() + file = nil + file_bytes = 0 + end + os.remove(options.thumbnail) + os.remove(options.thumbnail .. ".bgra") +end + +local activity_timer + +local function spawn(time) + if disabled then return end + + local path = properties["path"] + if path == nil then return end + + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + + local open_filename = properties["stream-open-filename"] + local ytdl = open_filename and properties["demuxer-via-network"] and path ~= open_filename + if ytdl then + path = open_filename + end + + remove_thumbnail_files() + + local vid = properties["vid"] + has_vid = vid or 0 + + local args = { + mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", + "--no-terminal", + "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", + "--load-auto-profiles=no", + "--edition=" .. (properties["edition"] or "auto"), "--vid=" .. (vid or "auto"), "--no-sub", "--no-audio", + "--start=" .. time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes", + "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", + "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", + "--hwdec=" .. (options.hwdec and "auto" or "no"), + "--vf=" .. vf_string(filters_all, true), + "--sws-scaler=fast-bilinear", + "--video-rotate=" .. last_rotate, + "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o=" .. options.thumbnail + } + + if not pre_0_30_0 then + table.insert(args, "--sws-allow-zimg=no") + end + + if os_name == "darwin" and properties["macos-app-activation-policy"] then + table.insert(args, "--macos-app-activation-policy=accessory") + end + + if os_name == "windows" or pre_0_33_0 then + table.insert(args, "--input-ipc-server=" .. options.socket) + elseif not script_written then + local client_script_path = options.socket .. ".run" + local script = io.open(client_script_path, "w+") + if script == nil then + mp.msg.error("client script write failed") + return + else + script_written = true + script:write(string.format(client_script, options.socket)) + script:close() + subprocess({ "chmod", "+x", client_script_path }, true) + table.insert(args, "--scripts=" .. client_script_path) + end + else + local client_script_path = options.socket .. ".run" + table.insert(args, "--scripts=" .. client_script_path) + end + + table.insert(args, "--") + table.insert(args, path) + + spawned = true + spawn_waiting = true + + subprocess(args, true, + function(success, result) + if spawn_waiting and (success == false or (result.status ~= 0 and result.status ~= -2)) then + spawned = false + spawn_waiting = false + options.tone_mapping = "no" + mp.msg.error("mpv subprocess create failed") + if not spawn_working then -- notify users of required configuration + if options.mpv_path == "mpv" then + if properties["current-vo"] == "libmpv" then + if options.mpv_path == mpv_path then -- attempt to locate ImPlay + mpv_path = "ImPlay" + spawn(time) + else -- ImPlay not in path + if os_name ~= "darwin" then + force_disabled = true + info(real_w or effective_w, real_h or effective_h) + end + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", + "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. + string.gsub(mp.command_native({ "expand-path", "~~/script-opts/thumbfast.conf" }), + "[/\\]", path_separator) .. "\nand restart ImPlay") + end + else + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + if os_name == "windows" then + mp.commandv("script-message-to", "mpvnet", "show-text", + "thumbfast: ERROR! install standalone mpv, see README", 5000, 20) + mp.commandv("script-message", "mpv.net", "show-text", + "thumbfast: ERROR! install standalone mpv, see README", 5000, 20) + end + end + else + mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000) + -- found ImPlay but not defined in config + mp.commandv("script-message-to", "implay", "show-message", "thumbfast", + "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. + string.gsub(mp.command_native({ "expand-path", "~~/script-opts/thumbfast.conf" }), "[/\\]", + path_separator) .. "\nand restart ImPlay") + end + end + elseif success == true and (result.status == 0 or result.status == -2) then + if not spawn_working and properties["current-vo"] == "libmpv" and options.mpv_path ~= mpv_path then + mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", + "Set mpv_path=ImPlay in thumbfast config:\n" .. + string.gsub(mp.command_native({ "expand-path", "~~/script-opts/thumbfast.conf" }), "[/\\]", + path_separator) .. "\nand restart ImPlay") + end + spawn_working = true + spawn_waiting = false + end + end + ) +end + +local function run(command) + if not spawned then return end + + if options.direct_io then + local hPipe = winapi.C.CreateFileW(winapi.socket_wc, winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, + winapi._createfile_pipe_flags, nil) + if hPipe ~= winapi.INVALID_HANDLE_VALUE then + local buf = command .. "\n" + winapi.C.SetNamedPipeHandleState(hPipe, winapi.PIPE_NOWAIT, nil, nil) + winapi.C.WriteFile(hPipe, buf, #buf + 1, winapi._lpNumberOfBytesWritten, nil) + winapi.C.CloseHandle(hPipe) + end + + return + end + + local command_n = command .. "\n" + + if os_name == "windows" then + if file and file_bytes + #command_n >= 4096 then + file:close() + file = nil + file_bytes = 0 + end + if not file then + file = io.open("\\\\.\\pipe\\" .. options.socket, "r+b") + end + elseif pre_0_33_0 then + subprocess({ "/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket }) + return + elseif not file then + file = io.open(options.socket, "r+") + end + if file then + file_bytes = file:seek("end") + file:write(command_n) + file:flush() + end +end + +local function draw(w, h, script) + if not w or not show_thumbnail then return end + if x ~= nil then + if pre_0_30_0 then + mp.command_native({ "overlay-add", options.overlay_id, x, y, options.thumbnail .. ".bgra", 0, "bgra", w, h, + (4 * w) }) + else + mp.command_native_async( + { "overlay-add", options.overlay_id, x, y, options.thumbnail .. ".bgra", 0, "bgra", w, h, (4 * w) }, + function() + end) + end + elseif script then + local json, _ = mp.utils.format_json({ + width = w, + height = h, + x = x, + y = y, + socket = options.socket, + thumbnail = options.thumbnail, + overlay_id = options.overlay_id + }) + mp.commandv("script-message-to", script, "thumbfast-render", json) + end +end + +local function real_res(req_w, req_h, filesize) + local count = filesize / 4 + local diff = (req_w * req_h) - count + + if (properties["video-params"] and properties["video-params"]["rotate"] or 0) % 180 == 90 then + req_w, req_h = req_h, req_w + end + + if diff == 0 then + return req_w, req_h + else + local threshold = 5 -- throw out results that change too much + local long_side, short_side = req_w, req_h + if req_h > req_w then + long_side, short_side = req_h, req_w + end + for a = short_side, short_side - threshold, -1 do + if count % a == 0 then + local b = count / a + if long_side - b < threshold then + if req_h < req_w then return b, a else return a, b end + end + end + end + return nil + end +end + +local function move_file(from, to) + if os_name == "windows" then + os.remove(to) + end + -- move the file because it can get overwritten while overlay-add is reading it, and crash the player + os.rename(from, to) +end + +local function seek(fast) + if last_seek_time then + run("async seek " .. last_seek_time .. (fast and " absolute+keyframes" or " absolute+exact")) + end +end + +local seek_period = 3 / 60 +local seek_period_counter = 0 +local seek_timer +seek_timer = mp.add_periodic_timer(seek_period, function() + if seek_period_counter == 0 then + seek(allow_fast_seek) + seek_period_counter = 1 + else + if seek_period_counter == 2 then + if allow_fast_seek then + seek_timer:kill() + seek() + end + else + seek_period_counter = seek_period_counter + 1 + end + end +end) +seek_timer:kill() + +local function request_seek() + if seek_timer:is_enabled() then + seek_period_counter = 0 + else + seek_timer:resume() + seek(allow_fast_seek) + seek_period_counter = 1 + end +end + +local function check_new_thumb() + -- the slave might start writing to the file after checking existance and + -- validity but before actually moving the file, so move to a temporary + -- location before validity check to make sure everything stays consistant + -- and valid thumbnails don't get overwritten by invalid ones + local tmp = options.thumbnail .. ".tmp" + move_file(options.thumbnail, tmp) + local finfo = mp.utils.file_info(tmp) + if not finfo then return false end + spawn_waiting = false + local w, h = real_res(effective_w, effective_h, finfo.size) + if w then -- only accept valid thumbnails + move_file(tmp, options.thumbnail .. ".bgra") + + real_w, real_h = w, h + if real_w and (real_w ~= last_real_w or real_h ~= last_real_h) then + last_real_w, last_real_h = real_w, real_h + info(real_w, real_h) + end + if not show_thumbnail then + file_timer:kill() + end + return true + end + + return false +end + +file_timer = mp.add_periodic_timer(file_check_period, function() + if check_new_thumb() then + draw(real_w, real_h, script_name) + end +end) +file_timer:kill() + +local function clear() + file_timer:kill() + seek_timer:kill() + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + last_seek_time = nil + show_thumbnail = false + last_x = nil + last_y = nil + if script_name then return end + if pre_0_30_0 then + mp.command_native({ "overlay-remove", options.overlay_id }) + else + mp.command_native_async({ "overlay-remove", options.overlay_id }, function() + end) + end +end + +local function quit() + activity_timer:kill() + if show_thumbnail then + activity_timer:resume() + return + end + run("quit") + spawned = false + real_w, real_h = nil, nil + clear() +end + +activity_timer = mp.add_timeout(options.quit_after_inactivity, quit) +activity_timer:kill() + +local function thumb(time, r_x, r_y, script) + if disabled then return end + + time = tonumber(time) + if time == nil then return end + + if r_x == "" or r_y == "" then + x, y = nil, nil + else + x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5) + end + + script_name = script + if last_x ~= x or last_y ~= y or not show_thumbnail then + show_thumbnail = true + last_x = x + last_y = y + draw(real_w, real_h, script) + end + + if options.quit_after_inactivity > 0 then + if show_thumbnail or activity_timer:is_enabled() then + activity_timer:kill() + end + activity_timer:resume() + end + + if time == last_seek_time then return end + last_seek_time = time + if not spawned then spawn(time) end + request_seek() + if not file_timer:is_enabled() then file_timer:resume() end +end + +local function watch_changes() + if not dirty or not properties["video-out-params"] then return end + dirty = false + + local old_w = effective_w + local old_h = effective_h + + calc_dimensions() + + local vf_reset = vf_string(filters_reset) + local rotate = properties["video-rotate"] or 0 + + local resized = old_w ~= effective_w or + old_h ~= effective_h or + last_vf_reset ~= vf_reset or + (last_rotate % 180) ~= (rotate % 180) or + par ~= last_par + + if resized then + last_rotate = rotate + info(effective_w, effective_h) + elseif last_has_vid ~= has_vid and has_vid ~= 0 then + info(effective_w, effective_h) + end + + if spawned then + if resized then + -- mpv doesn't allow us to change output size + local seek_time = last_seek_time + run("quit") + clear() + spawned = false + spawn(seek_time or mp.get_property_number("time-pos", 0)) + file_timer:resume() + else + if rotate ~= last_rotate then + run("set video-rotate " .. rotate) + end + local vf_runtime = vf_string(filters_runtime) + if vf_runtime ~= last_vf_runtime then + run("vf set " .. vf_string(filters_all, true)) + last_vf_runtime = vf_runtime + end + end + else + last_vf_runtime = vf_string(filters_runtime) + end + + last_vf_reset = vf_reset + last_rotate = rotate + last_par = par + last_has_vid = has_vid + + if not spawned and not disabled and options.spawn_first and resized then + spawn(mp.get_property_number("time-pos", 0)) + file_timer:resume() + end +end + +local function update_property(name, value) + properties[name] = value +end + +local function update_property_dirty(name, value) + properties[name] = value + dirty = true + if name == "tone-mapping" then + last_tone_mapping = nil + end +end + +local function update_tracklist(_, value) + -- current-tracks shim + for _, track in ipairs(value) do + if track.type == "video" and track.selected then + properties["current-tracks/video/image"] = track.image + properties["current-tracks/video/albumart"] = track.albumart + return + end + end +end + +local function sync_changes(prop, val) + update_property(prop, val) + if val == nil then return end + + if type(val) == "boolean" then + if prop == "vid" then + has_vid = 0 + last_has_vid = 0 + info(effective_w, effective_h) + clear() + return + end + val = val and "yes" or "no" + end + + if prop == "vid" then + has_vid = 1 + end + + if not spawned then return end + + run("set " .. prop .. " " .. val) + dirty = true +end + +local function file_load() + clear() + spawned = false + real_w, real_h = nil, nil + last_real_w, last_real_h = nil, nil + last_tone_mapping = nil + last_seek_time = nil + if info_timer then + info_timer:kill() + info_timer = nil + end + + calc_dimensions() + info(effective_w, effective_h) +end + +local function shutdown() + run("quit") + remove_thumbnail_files() + if os_name ~= "windows" then + os.remove(options.socket) + os.remove(options.socket .. ".run") + end +end + +local function on_duration(_, val) + allow_fast_seek = (val or 30) >= 30 +end + +mp.observe_property("current-tracks", "native", function(name, value) + if pre_0_33_0 then + mp.unobserve_property(update_tracklist) + pre_0_33_0 = false + end + update_property(name, value) +end) + +mp.observe_property("track-list", "native", update_tracklist) +mp.observe_property("display-hidpi-scale", "native", update_property_dirty) +mp.observe_property("video-out-params", "native", update_property_dirty) +mp.observe_property("video-params", "native", update_property_dirty) +mp.observe_property("vf", "native", update_property_dirty) +mp.observe_property("tone-mapping", "native", update_property_dirty) +mp.observe_property("demuxer-via-network", "native", update_property) +mp.observe_property("stream-open-filename", "native", update_property) +mp.observe_property("macos-app-activation-policy", "native", update_property) +mp.observe_property("current-vo", "native", update_property) +mp.observe_property("video-rotate", "native", update_property) +mp.observe_property("path", "native", update_property) +mp.observe_property("vid", "native", sync_changes) +mp.observe_property("edition", "native", sync_changes) +mp.observe_property("duration", "native", on_duration) + +mp.register_script_message("thumb", thumb) +mp.register_script_message("clear", clear) + +mp.register_event("file-loaded", file_load) +mp.register_event("shutdown", shutdown) + +mp.register_idle(watch_changes) diff --git a/multimedia/.config/mpv/scripts/uosc.lua b/multimedia/.config/mpv/scripts/uosc.lua index cdc3919..f8eeba9 100644 --- a/multimedia/.config/mpv/scripts/uosc.lua +++ b/multimedia/.config/mpv/scripts/uosc.lua @@ -1,3655 +1,1261 @@ ---[[ +--[[ uosc 4.7.0 - 2023-Apr-15 | https://github.com/tomasklaen/uosc ]] +local uosc_version = '4.7.0' -uosc 2.9.0 - 2020-May-11 | https://github.com/darsain/uosc +assdraw = require('mp.assdraw') +opt = require('mp.options') +utils = require('mp.utils') +msg = require('mp.msg') +osd = mp.create_osd_overlay('ass-events') +INFINITY = 1e309 +QUARTER_PI_SIN = math.sin(math.pi / 4) -Minimalist cursor proximity based UI for MPV player. +-- Enables relative requires from `scripts` directory +package.path = package.path .. ';' .. mp.find_config_file('scripts') .. '/?.lua' -uosc replaces the default osc UI, so that has to be disabled first. -Place these options into your `mpv.conf` file: +require('uosc_shared/lib/std') -``` -# required so that the 2 UIs don't fight each other -osc=no -# uosc provides its own seeking/volume indicators, so you also don't need this -osd-bar=no -# uosc will draw its own window controls if you disable window border -border=no -``` +--[[ OPTIONS ]] -Options go in `script-opts/uosc.conf`. Defaults: +defaults = { + timeline_style = 'line', + timeline_line_width = 2, + timeline_line_width_fullscreen = 3, + timeline_line_width_minimized_scale = 10, + timeline_size_min = 2, + timeline_size_max = 40, + timeline_size_min_fullscreen = 0, + timeline_size_max_fullscreen = 60, + timeline_start_hidden = false, + timeline_persistency = 'paused', + timeline_opacity = 0.9, + timeline_border = 1, + timeline_step = 5, + timeline_chapters_opacity = 0.8, + timeline_cache = true, -``` -# timeline size when fully retracted, 0 will hide it completely -timeline_size_min=2 -# timeline size when fully expanded, in pixels, 0 to disable -timeline_size_max=40 -# same as ^ but when in fullscreen -timeline_size_min_fullscreen=0 -timeline_size_max_fullscreen=60 -# same thing as calling toggle-progress command once on startup -timeline_start_hidden=no -# timeline opacity -timeline_opacity=0.8 -# top (and bottom in no-border mode) border of background color to help visually -# separate elapsed bar from a video of similar color or desktop background -timeline_border=1 -# when scrolling above timeline, wheel will seek by this amount of seconds -timeline_step=5 -# display seekable buffered ranges for streaming videos, syntax `color:opacity`, -# color is an BBGGRR hex code, set to `none` to disable -timeline_cached_ranges=345433:0.5 -# floating number font scale adjustment -timeline_font_scale=1 -# briefly show timeline on external changes (e.g. seeking with a hotkey) -timeline_flash=yes + controls = 'menu,gap,subtitles,audio,video,editions,stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen', + controls_size = 32, + controls_size_fullscreen = 40, + controls_margin = 8, + controls_spacing = 2, + controls_persistency = '', -# timeline chapters style: none, dots, lines, lines-top, lines-bottom -chapters=dots -chapters_opacity=0.3 + volume = 'right', + volume_size = 40, + volume_size_fullscreen = 52, + volume_persistency = '', + volume_opacity = 0.9, + volume_border = 1, + volume_step = 1, -# where to display volume controls: none, left, right -volume=right -volume_size=40 -volume_size_fullscreen=60 -volume_opacity=0.8 -volume_border=1 -volume_step=1 -volume_font_scale=1 -volume_flash=yes + speed_persistency = '', + speed_opacity = 0.6, + speed_step = 0.1, + speed_step_is_factor = false, -# playback speed widget: mouse drag or wheel to change, click to reset -speed=no -speed_size=46 -speed_size_fullscreen=68 -speed_opacity=1 -speed_step=0.1 -speed_font_scale=1 -speed_flash=yes + menu_item_height = 36, + menu_item_height_fullscreen = 50, + menu_min_width = 260, + menu_min_width_fullscreen = 360, + menu_opacity = 1, + menu_parent_opacity = 0.4, -# controls all menus, such as context menu, subtitle loader/selector, etc -menu_item_height=36 -menu_item_height_fullscreen=50 -menu_wasd_navigation=no -menu_hjkl_navigation=no -menu_opacity=0.8 -menu_font_scale=1 + top_bar = 'no-border', + top_bar_size = 40, + top_bar_size_fullscreen = 46, + top_bar_persistency = '', + top_bar_controls = true, + top_bar_title = 'yes', + top_bar_alt_title = '', + top_bar_alt_title_place = 'below', + top_bar_title_opacity = 0.8, -# top bar with window controls and media title shown only in no-border mode -top_bar_size=40 -top_bar_size_fullscreen=46 -top_bar_controls=yes -top_bar_title=yes + window_border_size = 1, + window_border_opacity = 0.8, -# pause video on clicks shorter than this number of milliseconds, 0 to disable -pause_on_click_shorter_than=0 -# for how long in milliseconds to show elements they're it's being flashed -flash_duration=400 -# distances in pixels below which elements are fully faded in/out -proximity_in=40 -proximity_out=120 -# BBGGRR - BLUE GREEN RED hex color codes -color_foreground=ffffff -color_foreground_text=000000 -color_background=000000 -color_background_text=ffffff -# use bold font weight throughout the whole UI -font_bold=no -# hide UI when mpv autohides the cursor -autohide=no -# can be: none, flash, static -pause_indicator=flash -# load first file when calling next on a last file in a directory and vice versa -directory_navigation_loops=no -# file types to look for when navigating media files -media_types=3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv -# file types to look for when loading external subtitles -subtitle_types=aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt -# used to approximate text width -# if you are using some wide font and see a lot of right side clipping in menus, -# try bumping this up -font_height_to_letter_width_ratio=0.5 + autoload = false, + autoload_types = 'video,audio,image', + shuffle = false, -# `chapter_ranges` lets you transform chapter indicators into range indicators. -# -# Chapter range definition syntax: -# ``` -# start_patternend_pattern -# ``` -# -# Multiple start and end patterns can be defined by separating them with `|`: -# ``` -# p1|pNp1|pN -# ``` -# -# Multiple chapter ranges can be defined by separating them with comma: -# -# chapter_ranges=range1,rangeN -# -# One of `start_pattern`s can be a custom keyword `{bof}` that will match -# beginning of file when it makes sense. -# -# One of `end_pattern`s can be a custom keyword `{eof}` that will match end of -# file when it makes sense. -# -# Patterns are lua patterns (http://lua-users.org/wiki/PatternsTutorial). -# They only need to occur in a title, not match it completely. -# Matching is case insensitive. -# -# `color` is a `bbggrr` hexadecimal color code. -# `opacity` is a float number from 0 to 1. -# -# Examples: -# -# Display skippable youtube video sponsor blocks from https://github.com/po5/mpv_sponsorblock -# ``` -# chapter_ranges=sponsor start<3535a5:0.5>sponsor end -# ``` -# -# Display anime openings and endings as ranges: -# ``` -# chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof} -# ``` -chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end -``` - -Available keybindings (place into `input.conf`): - -``` -Key script-binding uosc/peek-timeline -Key script-binding uosc/toggle-progress -Key script-binding uosc/menu -Key script-binding uosc/load-subtitles -Key script-binding uosc/subtitles -Key script-binding uosc/audio -Key script-binding uosc/video -Key script-binding uosc/playlist -Key script-binding uosc/chapters -Key script-binding uosc/open-file -Key script-binding uosc/next -Key script-binding uosc/prev -Key script-binding uosc/first -Key script-binding uosc/last -Key script-binding uosc/next-file -Key script-binding uosc/prev-file -Key script-binding uosc/first-file -Key script-binding uosc/last-file -Key script-binding uosc/delete-file-next -Key script-binding uosc/delete-file-quit -Key script-binding uosc/show-in-directory -Key script-binding uosc/open-config-directory -``` -]] if mp.get_property('osc') == 'yes' then - mp.msg.info('Disabled because original osc is enabled!') - return -end - -local assdraw = require('mp.assdraw') -local opt = require('mp.options') -local utils = require('mp.utils') -local msg = require('mp.msg') -local osd = mp.create_osd_overlay('ass-events') -local infinity = 1e309 - --- OPTIONS/CONFIG/STATE -local options = { - timeline_size_min = 2, - timeline_size_max = 40, - timeline_size_min_fullscreen = 0, - timeline_size_max_fullscreen = 60, - timeline_start_hidden = false, - timeline_opacity = 0.8, - timeline_border = 1, - timeline_step = 5, - timeline_cached_ranges = '345433:0.5', - timeline_font_scale = 1, - timeline_flash = true, - - chapters = 'dots', - chapters_opacity = 0.3, - - volume = 'right', - volume_size = 40, - volume_size_fullscreen = 60, - volume_opacity = 0.8, - volume_border = 1, - volume_step = 1, - volume_font_scale = 1, - volume_flash = true, - - speed = false, - speed_size = 46, - speed_size_fullscreen = 68, - speed_opacity = 1, - speed_step = 0.1, - speed_font_scale = 1, - speed_flash = true, - - menu_item_height = 36, - menu_item_height_fullscreen = 50, - menu_wasd_navigation = false, - menu_hjkl_navigation = false, - menu_opacity = 0.8, - menu_font_scale = 1, - - top_bar_size = 40, - top_bar_size_fullscreen = 46, - top_bar_controls = true, - top_bar_title = true, - - pause_on_click_shorter_than = 0, - flash_duration = 400, - proximity_in = 40, - proximity_out = 120, - color_foreground = 'ffffff', - color_foreground_text = '000000', - color_background = '000000', - color_background_text = 'ffffff', - font_bold = false, - autohide = false, - pause_indicator = 'flash', - directory_navigation_loops = false, - media_types = '3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv', - subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt', - font_height_to_letter_width_ratio = 0.5, - chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end' + ui_scale = 1, + font_scale = 1, + text_border = 1.2, + text_width_estimation = true, + pause_on_click_shorter_than = 0, -- deprecated by below + click_threshold = 0, + click_command = 'cycle pause; script-binding uosc/flash-pause-indicator', + flash_duration = 1000, + proximity_in = 40, + proximity_out = 120, + foreground = 'ffffff', + foreground_text = '000000', + background = '000000', + background_text = 'ffffff', + total_time = false, -- deprecated by below + destination_time = 'playtime-remaining', + time_precision = 0, + font_bold = false, + autohide = false, + buffered_time_threshold = 60, + pause_indicator = 'flash', + curtain_opacity = 0.5, + stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144', + video_types= '3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg,ogm,ogv,rm,rmvb,ts,vob,webm,wmv,y4m', + audio_types= 'aac,ac3,aiff,ape,au,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv', + image_types= 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp', + subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt', + default_directory = '~/', + use_trash = false, + adjust_osd_margins = true, + chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80', + chapter_range_patterns = 'openings:オープニング;endings:エンディング', } +options = table_shallow_copy(defaults) opt.read_options(options, 'uosc') -local config = { - render_delay = 0.03, -- sets max rendering frequency - font = mp.get_property('options/osd-font'), - menu_parent_opacity = 0.4, - menu_min_width = 260 -} -local bold_tag = options.font_bold and '\\b1' or '' -local display = {width = 1280, height = 720, aspect = 1.77778} -local cursor = { - hidden = true, -- true when autohidden or outside of the player window - x = 0, - y = 0 -} -local state = { - os = (function() - if os.getenv('windir') ~= nil then return 'windows' end - local homedir = os.getenv('HOME') - if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then - return 'macos' - end - return 'linux' - end)(), - cwd = mp.get_property('working-directory'), - media_title = '', - duration = nil, - position = nil, - pause = false, - chapters = nil, - chapter_ranges = nil, - fullscreen = mp.get_property_native('fullscreen'), - maximized = mp.get_property_native('window-maximized'), - render_timer = nil, - render_last_time = 0, - volume = nil, - volume_max = nil, - mute = nil, - cursor_autohide_timer = mp.add_timeout( - mp.get_property_native('cursor-autohide') / 1000, function() - if not options.autohide then return end - handle_mouse_leave() - end), - mouse_bindings_enabled = false, - cached_ranges = nil -} -local forced_key_bindings -- defined at the bottom next to events - --- HELPERS - -function round(number) - local modulus = number % 1 - return modulus < 0.5 and math.floor(number) or math.ceil(number) +-- Normalize values +options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) +if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end +if options.pause_on_click_shorter_than > 0 and options.click_threshold == 0 then + msg.warn('`pause_on_click_shorter_than` is deprecated. Use `click_threshold` and `click_command` instead.') + options.click_threshold = options.pause_on_click_shorter_than end - -function call_me_maybe(fn, value1, value2, value3) - if fn then fn(value1, value2, value3) end +if options.total_time and options.destination_time == 'playtime-remaining' then + msg.warn('`total_time` is deprecated. Use `destination_time` instead.') + options.destination_time = 'total' +elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then + options.destination_time = 'playtime-remaining' end +-- Ensure required environment configuration +if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end +-- Color shorthands +fg, bg = serialize_rgba(options.foreground).color, serialize_rgba(options.background).color +fgt, bgt = serialize_rgba(options.foreground_text).color, serialize_rgba(options.background_text).color -function split(str, pattern) - local list = {} - local full_pattern = '(.-)' .. pattern - local last_end = 1 - local start_index, end_index, capture = str:find(full_pattern, 1) - while start_index do - list[#list + 1] = capture - last_end = end_index + 1 - start_index, end_index, capture = str:find(full_pattern, last_end) - end - if last_end <= (#str + 1) then - capture = str:sub(last_end) - list[#list + 1] = capture - end - return list -end +--[[ CONFIG ]] -function itable_find(haystack, needle) - local is_needle = type(needle) == 'function' and needle or - function(index, value) return value == needle end - for index, value in ipairs(haystack) do - if is_needle(index, value) then return index, value end - end -end - -function itable_filter(haystack, needle) - local is_needle = type(needle) == 'function' and needle or - function(index, value) return value == needle end - local filtered = {} - for index, value in ipairs(haystack) do - if is_needle(index, value) then filtered[#filtered + 1] = value end - end - return filtered -end - -function itable_remove(haystack, needle) - local should_remove = type(needle) == 'function' and needle or - function(value) return value == needle end - local new_table = {} - for _, value in ipairs(haystack) do - if not should_remove(value) then - new_table[#new_table + 1] = value - end - end - return new_table -end - -function itable_slice(haystack, start_pos, end_pos) - start_pos = start_pos and start_pos or 1 - end_pos = end_pos and end_pos or #haystack - - if end_pos < 0 then end_pos = #haystack + end_pos + 1 end - if start_pos < 0 then start_pos = #haystack + start_pos + 1 end - - local new_table = {} - for index, value in ipairs(haystack) do - if index >= start_pos and index <= end_pos then - new_table[#new_table + 1] = value - end - end - return new_table -end - -function table_copy(table) - local new_table = {} - for key, value in pairs(table) do new_table[key] = value end - return new_table -end - --- Sorting comparator close to (but not exactly) how file explorers sort files -local word_order_comparator = (function() - local symbol_order - local default_order - - if state.os == 'win' then - symbol_order = { - ['!'] = 1, - ['#'] = 2, - ['$'] = 3, - ['%'] = 4, - ['&'] = 5, - ['('] = 6, - [')'] = 6, - [','] = 7, - ['.'] = 8, - ["'"] = 9, - ['-'] = 10, - [';'] = 11, - ['@'] = 12, - ['['] = 13, - [']'] = 13, - ['^'] = 14, - ['_'] = 15, - ['`'] = 16, - ['{'] = 17, - ['}'] = 17, - ['~'] = 18, - ['+'] = 19, - ['='] = 20 - } - default_order = 21 - else - symbol_order = { - ['`'] = 1, - ['^'] = 2, - ['~'] = 3, - ['='] = 4, - ['_'] = 5, - ['-'] = 6, - [','] = 7, - [';'] = 8, - ['!'] = 9, - ["'"] = 10, - ['('] = 11, - [')'] = 11, - ['['] = 12, - [']'] = 12, - ['{'] = 13, - ['}'] = 14, - ['@'] = 15, - ['$'] = 16, - ['*'] = 17, - ['&'] = 18, - ['%'] = 19, - ['+'] = 20, - ['.'] = 22, - ['#'] = 23 - } - default_order = 21 - end - - return function(a, b) - a = a:lower() - b = b:lower() - for i = 1, math.max(#a, #b) do - local ai = a:sub(i, i) - local bi = b:sub(i, i) - if ai == nil and bi then return true end - if bi == nil and ai then return false end - local a_order = symbol_order[ai] or default_order - local b_order = symbol_order[bi] or default_order - if a_order == b_order then - return a < b - else - return a_order < b_order - end - end - end -end)() - --- Creates in-between frames to animate value from `from` to `to` numbers. --- Returns function that terminates animation. --- `to` can be a function that returns target value, useful for movable targets. --- `speed` is an optional float between 1-instant and 0-infinite duration --- `callback` is called either on animation end, or when animation is canceled -function tween(from, to, setter, speed, callback) - if type(speed) ~= 'number' then - callback = speed - speed = 0.3 - end - local timeout - local getTo = type(to) == 'function' and to or function() return to end - local cutoff = math.abs(getTo() - from) * 0.01 - function tick() - from = from + ((getTo() - from) * speed) - local is_end = math.abs(getTo() - from) <= cutoff - setter(is_end and getTo() or from) - request_render() - if is_end then - call_me_maybe(callback) - else - timeout:resume() - end - end - timeout = mp.add_timeout(0.016, tick) - tick() - return function() - timeout:kill() - call_me_maybe(callback) - end -end - --- Kills ongoing animation if one is already running on this element. --- Killed animation will not get its `on_end` called. -function tween_element(element, from, to, setter, speed, callback) - if type(speed) ~= 'number' then - callback = speed - speed = 0.3 - end - - tween_element_stop(element) - - element.stop_current_animation = tween(from, to, function(value) - setter(element, value) - end, speed, function() - element.stop_current_animation = nil - call_me_maybe(callback, element) - end) -end - --- Stopped animation will not get its on_end called. -function tween_element_is_tweening(element) - return element and element.stop_current_animation -end - --- Stopped animation will not get its on_end called. -function tween_element_stop(element) - call_me_maybe(element and element.stop_current_animation) -end - --- Helper to automatically use an element property setter -function tween_element_property(element, prop, from, to, speed, callback) - tween_element(element, from, to, - function(_, value) element[prop] = value end, speed, callback) -end - -function get_point_to_rectangle_proximity(point, rect) - local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx + 1) - local dy = math.max(rect.ay - point.y, 0, point.y - rect.by + 1) - return math.sqrt(dx * dx + dy * dy); -end - -function text_width_estimate(letters, font_size) - return letters and letters * font_size * - options.font_height_to_letter_width_ratio or 0 -end - -function opacity_to_alpha(opacity) return 255 - math.ceil(255 * opacity) end - -function ass_opacity(opacity, fraction) - fraction = fraction ~= nil and fraction or 1 - if type(opacity) == 'number' then - return string.format('{\\alpha&H%X&}', - opacity_to_alpha(opacity * fraction)) - else - return string.format('{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}', - opacity_to_alpha((opacity[1] or 0) * fraction), - opacity_to_alpha((opacity[2] or 0) * fraction), - opacity_to_alpha((opacity[3] or 0) * fraction), - opacity_to_alpha((opacity[4] or 0) * fraction)) - end -end - --- Ensures path is absolute and normalizes slashes to the current platform -function normalize_path(path) - if not path or is_protocol(path) then return path end - - -- Ensure path is absolute - if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then - path = utils.join_path(state.cwd, path) - end - - -- Use proper slashes - if state.os == 'windows' then - return path:gsub('/', '\\') - else - return path:gsub('\\', '/') - end -end - --- Check if path is a protocol, such as `http://...` -function is_protocol(path) return path:match('^%a[%a%d-_]+://') end - -function get_extension(path) - local parts = split(path, '%.') - return parts and #parts > 1 and parts[#parts] or nil -end - --- Serializes path into its semantic parts -function serialize_path(path) - if not path or is_protocol(path) then return end - path = normalize_path(path) - local parts = split(path, '[\\/]+') - if parts[#parts] == '' then table.remove(parts, #parts) end -- remove trailing separator - local basename = parts and parts[#parts] or path - local dirname = #parts > 1 and - table.concat(itable_slice(parts, 1, #parts - 1), - state.os == 'windows' and '\\' or '/') or - nil - local dot_split = split(basename, '%.') - return { - path = path:sub(-1) == ':' and state.os == 'windows' and path .. '\\' or - path, - is_root = dirname == nil, - dirname = dirname, - basename = basename, - filename = #dot_split > 1 and - table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or - basename, - extension = #dot_split > 1 and dot_split[#dot_split] or nil - } -end - -function get_files_in_directory(directory, allowed_types) - local files, error = utils.readdir(directory, 'files') - - if not files then - msg.error('Retrieving files failed: ' .. (error or '')) - return - end - - -- Filter only requested file types - if allowed_types then - files = itable_filter(files, function(_, file) - local extension = get_extension(file) - return extension and itable_find(allowed_types, extension:lower()) - end) - end - - table.sort(files, word_order_comparator) - - return files -end - -function get_adjacent_file(file_path, direction, allowed_types) - local current_file = serialize_path(file_path) - local files = get_files_in_directory(current_file.dirname, allowed_types) - - if not files then return end - - for index, file in ipairs(files) do - if current_file.basename == file then - if direction == 'forward' then - if files[index + 1] then - return utils.join_path(current_file.dirname, - files[index + 1]) - end - if options.directory_navigation_loops and files[1] then - return utils.join_path(current_file.dirname, files[1]) - end - else - if files[index - 1] then - return utils.join_path(current_file.dirname, - files[index - 1]) - end - if options.directory_navigation_loops and files[#files] then - return utils.join_path(current_file.dirname, files[#files]) - end - end - - -- This is the only file in directory - return nil - end - end -end - --- Ensures chapters are in chronological order -function get_normalized_chapters() - local chapters = mp.get_property_native('chapter-list') - - if not chapters then return end - - -- Copy table - chapters = itable_slice(chapters) - - -- Ensure chronological order of chapters - table.sort(chapters, function(a, b) return a.time < b.time end) - - return chapters -end - --- Element ---[[ -Signature: -{ - -- enables capturing button groups for this element - captures = {mouse_buttons = true, wheel = true}, - -- element rectangle coordinates - ax = 0, ay = 0, bx = 0, by = 0, - -- cursor<>element relative proximity as a 0-1 floating number - -- where 0 = completely away, and 1 = touching/hovering - -- so it's easy to work with and throw into equations - proximity = 0, - -- raw cursor<>element proximity in pixels - proximity_raw = infinity, - -- called when element is created - ?init = function(this), - -- called manually when disposing of element - ?destroy = function(this), - -- triggered when event happens and cursor is above element - ?on_{event_name} = function(this), - -- triggered when any event happens anywhere on a page - ?on_global_{event_name} = function(this), - -- object - ?render = function(this_element), -} -]] -local Element = { - captures = nil, - ax = 0, - ay = 0, - bx = 0, - by = 0, - proximity = 0, - proximity_raw = infinity -} -Element.__index = Element - -function Element.new(props) - local element = setmetatable(props, Element) - element._eventListeners = {} - - -- Flash timer - element._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, - function() - local getTo = function() return element.proximity end - element:tween_property('forced_proximity', 1, getTo, - function() element.forced_proximity = nil end) - end) - element._flash_out_timer:kill() - - element:init() - - return element -end - -function Element:init() end -function Element:destroy() end - --- Call method if it exists -function Element:maybe(name, ...) - if self[name] then return self[name](self, ...) end -end - --- Tween helpers -function Element:tween(...) tween_element(self, ...) end -function Element:tween_property(...) tween_element_property(self, ...) end -function Element:tween_stop() tween_element_stop(self) end -function Element:is_tweening() tween_element_is_tweening(self) end - --- Event listeners -function Element:on(name, handler) - if self._eventListeners[name] == nil then self._eventListeners[name] = {} end - local preexistingIndex = itable_find(self._eventListeners[name], handler) - if preexistingIndex then - return - else - self._eventListeners[name][#self._eventListeners[name] + 1] = handler - end -end -function Element:off(name, handler) - if self._eventListeners[name] == nil then return end - local index = itable_find(self._eventListeners, handler) - if index then table.remove(self._eventListeners, index) end -end -function Element:trigger(name, ...) - self:maybe('on_' .. name, ...) - if self._eventListeners[name] == nil then return end - for _, handler in ipairs(self._eventListeners[name]) do handler(...) end -end - --- Briefly flashes the element for `options.flash_duration` milliseconds. --- Useful to visualize changes of volume and timeline when changed via hotkeys. --- Implemented by briefly adding animated `forced_proximity` property to the element. -function Element:flash() - if options.flash_duration > 0 and - (self.proximity < 1 or self._flash_out_timer:is_enabled()) then - self:tween_stop() - self.forced_proximity = 1 - self._flash_out_timer:kill() - self._flash_out_timer:resume() - end -end - --- ELEMENTS - -local Elements = {itable = {}} -Elements.__index = Elements -local elements = setmetatable({}, Elements) - -function Elements:add(name, element) - local insert_index = #Elements.itable + 1 - - -- Replace if element already exists - if self:has(name) then - insert_index = itable_find(Elements.itable, function(_, element) - return element.name == name - end) - end - - element.name = name - Elements.itable[insert_index] = element - self[name] = element - - request_render() -end - -function Elements:remove(name, props) - Elements.itable = itable_remove(Elements.itable, self[name]) - self[name] = nil - request_render() -end - -function Elements:has(name) return self[name] ~= nil end -function Elements:ipairs() return ipairs(Elements.itable) end -function Elements:pairs(elements) return pairs(self) end - --- MENU ---[[ -Usage: -``` -local items = { - {title = 'Foo title', hint = 'Ctrl+F', value = 'foo'}, - {title = 'Bar title', hint = 'Ctrl+B', value = 'bar'}, - { - title = 'Submenu', - items = { - {title = 'Sub item 1', value = 'sub1'}, - {title = 'Sub item 2', value = 'sub2'} - } +function create_default_menu() + return { + {title = 'Subtitles', value = 'script-binding uosc/subtitles'}, + {title = 'Audio tracks', value = 'script-binding uosc/audio'}, + {title = 'Stream quality', value = 'script-binding uosc/stream-quality'}, + {title = 'Playlist', value = 'script-binding uosc/items'}, + {title = 'Chapters', value = 'script-binding uosc/chapters'}, + {title = 'Navigation', items = { + {title = 'Next', hint = 'playlist or file', value = 'script-binding uosc/next'}, + {title = 'Prev', hint = 'playlist or file', value = 'script-binding uosc/prev'}, + {title = 'Delete file & Next', value = 'script-binding uosc/delete-file-next'}, + {title = 'Delete file & Prev', value = 'script-binding uosc/delete-file-prev'}, + {title = 'Delete file & Quit', value = 'script-binding uosc/delete-file-quit'}, + {title = 'Open file', value = 'script-binding uosc/open-file'}, + },}, + {title = 'Utils', items = { + {title = 'Aspect ratio', items = { + {title = 'Default', value = 'set video-aspect-override "-1"'}, + {title = '16:9', value = 'set video-aspect-override "16:9"'}, + {title = '4:3', value = 'set video-aspect-override "4:3"'}, + {title = '2.35:1', value = 'set video-aspect-override "2.35:1"'}, + },}, + {title = 'Audio devices', value = 'script-binding uosc/audio-device'}, + {title = 'Editions', value = 'script-binding uosc/editions'}, + {title = 'Screenshot', value = 'async screenshot'}, + {title = 'Show in directory', value = 'script-binding uosc/show-in-directory'}, + {title = 'Open config folder', value = 'script-binding uosc/open-config-directory'}, + },}, + {title = 'Quit', value = 'quit'}, } +end + +config = { + version = uosc_version, + -- sets max rendering frequency in case the + -- native rendering frequency could not be detected + render_delay = 1 / 60, + font = mp.get_property('options/osd-font'), + osd_margin_x = mp.get_property('osd-margin-x'), + osd_margin_y = mp.get_property('osd-margin-y'), + osd_alignment_x = mp.get_property('osd-align-x'), + osd_alignment_y = mp.get_property('osd-align-y'), + types = { + video = split(options.video_types, ' *, *'), + audio = split(options.audio_types, ' *, *'), + image = split(options.image_types, ' *, *'), + subtitle = split(options.subtitle_types, ' *, *'), + media = split(options.video_types .. ',' .. options.audio_types .. ',' .. options.image_types, ' *, *'), + autoload = (function() + ---@type string[] + local option_values = {} + for _, name in ipairs(split(options.autoload_types, ' *, *')) do + local value = options[name .. '_types'] + if type(value) == 'string' then option_values[#option_values + 1] = value end + end + return split(table.concat(option_values, ','), ' *, *') + end)(), + }, + stream_quality_options = split(options.stream_quality_options, ' *, *'), + menu_items = (function() + local input_conf_property = mp.get_property_native('input-conf') + local input_conf_path = mp.command_native({ + 'expand-path', input_conf_property == '' and '~~/input.conf' or input_conf_property, + }) + local input_conf_meta, meta_error = utils.file_info(input_conf_path) + + -- File doesn't exist + if not input_conf_meta or not input_conf_meta.is_file then return create_default_menu() end + + local main_menu = {items = {}, items_by_command = {}} + local by_id = {} + + for line in io.lines(input_conf_path) do + local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$') + local title = '' + if comment then + local comments = split(comment, '#') + local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end) + if titles and #titles > 0 then + title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*') + end + end + if title ~= '' then + local is_dummy = key:sub(1, 1) == '#' + local submenu_id = '' + local target_menu = main_menu + local title_parts = split(title or '', ' *> *') + + for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do + if index < #title_parts then + submenu_id = submenu_id .. title_part + + if not by_id[submenu_id] then + local items = {} + by_id[submenu_id] = {items = items, items_by_command = {}} + target_menu.items[#target_menu.items + 1] = {title = title_part, items = items} + end + + target_menu = by_id[submenu_id] + else + if command == 'ignore' then break end + -- If command is already in menu, just append the key to it + if target_menu.items_by_command[command] then + local hint = target_menu.items_by_command[command].hint + target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key + else + local item = { + title = title_part, + hint = not is_dummy and key or nil, + value = command, + } + target_menu.items_by_command[command] = item + target_menu.items[#target_menu.items + 1] = item + end + end + end + end + end + + if #main_menu.items > 0 then + return main_menu.items + else + -- Default context menu + return create_default_menu() + end + end)(), + chapter_ranges = (function() + ---@type table Alternative patterns. + local alt_patterns = {} + if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then + for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do + local name_patterns = split(definition, ' *:') + local name, patterns = name_patterns[1], name_patterns[2] + if name and patterns then alt_patterns[name] = split(patterns, ',') end + end + end + + ---@type table + local ranges = {} + if options.chapter_ranges and options.chapter_ranges ~= '' then + for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do + local name_color = split(definition, ' *:+ *') + local name, color = name_color[1], name_color[2] + if name and color + and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$') + and (#color == 6 or #color == 8) then + local range = serialize_rgba(name_color[2]) + range.patterns = alt_patterns[name] + ranges[name_color[1]] = range + end + end + end + return ranges + end)(), } - -function open_item(value) - value -- value from `item.value` +-- Adds `{element}_persistency` property with table of flags when the element should be visible (`{paused = true}`) +for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do + local option_name = name .. '_persistency' + local value, flags = options[option_name], {} + if type(value) == 'string' then + for _, state in ipairs(split(value, ' *, *')) do flags[state] = true end + end + config[option_name] = flags end -menu:open(items, open_item) -``` -]] -local Menu = {} -Menu.__index = Menu -local menu = setmetatable({key_bindings = {}, is_closing = false}, Menu) +--[[ STATE ]] -function Menu:is_open(menu_type) - return elements.menu ~= nil and - (not menu_type or elements.menu.type == menu_type) -end +display = {width = 1280, height = 720, scale_x = 1, scale_y = 1, initialized = false} +cursor = { + x = 0, + y = 0, + hidden = true, + hover_raw = false, + -- Event handlers that are only fired on cursor, bound during render loop. Guidelines: + -- - element activations (clicks) go to `on_primary_down` handler + -- - `on_primary_up` is only for clearing dragging/swiping, and prevents autohide when bound + on_primary_down = nil, + on_primary_up = nil, + on_wheel_down = nil, + on_wheel_up = nil, + -- Called at the beginning of each render + reset_handlers = function() + cursor.on_primary_down, cursor.on_primary_up = nil, nil + cursor.on_wheel_down, cursor.on_wheel_up = nil, nil + end, + -- Enables pointer key group captures needed by handlers (called at the end of each render) + mbtn_left_enabled = nil, + wheel_enabled = nil, + decide_keybinds = function() + local enable_mbtn_left = (cursor.on_primary_down or cursor.on_primary_up) ~= nil + local enable_wheel = (cursor.on_wheel_down or cursor.on_wheel_up) ~= nil + if enable_mbtn_left ~= cursor.mbtn_left_enabled then + mp[(enable_mbtn_left and 'enable' or 'disable') .. '_key_bindings']('mbtn_left') + cursor.mbtn_left_enabled = enable_mbtn_left + end + if enable_wheel ~= cursor.wheel_enabled then + mp[(enable_wheel and 'enable' or 'disable') .. '_key_bindings']('wheel') + cursor.wheel_enabled = enable_wheel + end + end, + -- Cursor auto-hiding after period of inactivity + autohide = function() + if not cursor.on_primary_up and not Menu:is_open() then handle_mouse_leave() end + end, + autohide_timer = (function() + local timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function() cursor.autohide() end) + timer:kill() + return timer + end)(), + queue_autohide = function() + if options.autohide and not cursor.on_primary_up then + cursor.autohide_timer:kill() + cursor.autohide_timer:resume() + end + end +} +state = { + platform = (function() + local platform = mp.get_property_native('platform') + if platform then + if itable_index_of({'windows', 'darwin'}, platform) then return platform end + else + if os.getenv('windir') ~= nil then return 'windows' end + local homedir = os.getenv('HOME') + if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'darwin' end + end + return 'linux' + end)(), + cwd = mp.get_property('working-directory'), + path = nil, -- current file path or URL + title = nil, + alt_title = nil, + time = nil, -- current media playback time + speed = 1, + duration = nil, -- current media duration + time_human = nil, -- current playback time in human format + destination_time_human = nil, -- depends on options.destination_time + pause = mp.get_property_native('pause'), + chapters = {}, + current_chapter = nil, + chapter_ranges = {}, + border = mp.get_property_native('border'), + fullscreen = mp.get_property_native('fullscreen'), + maximized = mp.get_property_native('window-maximized'), + fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'), + render_timer = nil, + render_last_time = 0, + volume = nil, + volume_max = nil, + mute = nil, + is_idle = false, + is_video = false, + is_audio = false, -- true if file is audio only (mp3, etc) + is_image = false, + is_stream = false, + has_audio = false, + has_sub = false, + has_chapter = false, + has_playlist = false, + shuffle = options.shuffle, + mouse_bindings_enabled = false, + uncached_ranges = nil, + cache = nil, + cache_buffering = 100, + cache_underrun = false, + core_idle = false, + eof_reached = false, + render_delay = config.render_delay, + first_real_mouse_move_received = false, + playlist_count = 0, + playlist_pos = 0, + margin_top = 0, + margin_bottom = 0, + margin_left = 0, + margin_right = 0, + hidpi_scale = 1, +} +thumbnail = {width = 0, height = 0, disabled = false} +external = {} -- Properties set by external scripts +key_binding_overwrites = {} -- Table of key_binding:mpv_command +Elements = require('uosc_shared/elements/Elements') +Menu = require('uosc_shared/elements/Menu') -function Menu:open(items, open_item, opts) - opts = opts or {} +-- State dependent utilities +require('uosc_shared/lib/utils') +require('uosc_shared/lib/text') +require('uosc_shared/lib/ass') +require('uosc_shared/lib/menus') - if menu:is_open() then - if not opts.parent_menu then - menu:close(true, function() - menu:open(items, open_item, opts) - end) - return - end - else - menu:enable_key_bindings() - elements.curtain:fadein() - end - - elements:add('menu', Element.new({ - captures = {mouse_buttons = true}, - type = nil, -- menu type such as `menu`, `chapters`, ... - title = nil, - width = nil, - height = nil, - offset_x = 0, -- used to animated from/to left when submenu - item_height = nil, - item_spacing = 1, - item_content_spacing = nil, - font_size = nil, - scroll_step = nil, - scroll_height = nil, - scroll_y = 0, - opacity = 0, - relative_parent_opacity = 0.4, - items = items, - active_item = nil, - selected_item = nil, - open_item = open_item, - parent_menu = nil, - init = function(this) - -- Already initialized - if this.width ~= nil then return end - - -- Apply options - for key, value in pairs(opts) do this[key] = value end - this.selected_item = this.active_item - - -- Set initial dimensions - this:on_display_resize() - - -- Scroll to active item - this:scroll_to_item(this.active_item) - - -- Transition in animation - menu.transition = {to = 'child', target = this} - local start_offset = this.parent_menu and - (this.parent_menu.width + this.width) / 2 or - 0 - - tween_element(menu.transition.target, 0, 1, function(_, pos) - this:set_offset_x(round(start_offset * (1 - pos))) - this.opacity = pos - this:set_parent_opacity(1 - - ((1 - config.menu_parent_opacity) * - pos)) - end, function() - menu.transition = nil - update_proximities() - end) - end, - destroy = function(this) request_render() end, - on_display_resize = function(this) - this.item_height = (state.fullscreen or state.maximized) and - options.menu_item_height_fullscreen or - options.menu_item_height - this.font_size = round(this.item_height * 0.48 * - options.menu_font_scale) - this.item_content_spacing = round( - (this.item_height - this.font_size) * - 0.6) - this.scroll_step = this.item_height + this.item_spacing - - -- Estimate width of a widest item - local estimated_max_width = 0 - for _, item in ipairs(items) do - local item_text_length = - ((item.title and item.title:len() or 0) + - (item.hint and item.hint:len() or 0)) - local spacings_in_item = item.hint and 3 or 2 - local estimated_width = text_width_estimate(item_text_length, - this.font_size) + - (this.item_content_spacing * - spacings_in_item) - if estimated_width > estimated_max_width then - estimated_max_width = estimated_width - end - end - - -- Also check menu title - local menu_title_length = this.title and this.title:len() or 0 - local estimated_menu_title_width = - text_width_estimate(menu_title_length, this.font_size) - if estimated_menu_title_width > estimated_max_width then - estimated_max_width = estimated_menu_title_width - end - - -- Coordinates and sizes are of the scrollable area to make - -- consuming values in rendering easier. Title drawn above this, so - -- we need to account for that in max_height and ay position. - this.width = round(math.min(math.max(estimated_max_width, - config.menu_min_width), - display.width * 0.9)) - local title_height = this.title and this.scroll_step or 0 - local max_height = round(display.height * 0.9) - title_height - this.height = math.min(round(this.scroll_step * #items) - - this.item_spacing, max_height) - this.scroll_height = math.max( - (this.scroll_step * #this.items) - - this.height - this.item_spacing, 0) - this.ax = round((display.width - this.width) / 2) + this.offset_x - this.ay = round((display.height - this.height) / 2 + - (title_height / 2)) - this.bx = round(this.ax + this.width) - this.by = round(this.ay + this.height) - - if this.parent_menu then - this.parent_menu:on_display_resize() - end - end, - set_items = function(this, items, props) - this.items = items - this.selected_item = nil - this.active_item = nil - if props then - for key, value in pairs(props) do - this[key] = value - end - end - this:on_display_resize() - request_render() - end, - set_offset_x = function(this, offset) - local delta = offset - this.offset_x - this.offset_x = offset - this.ax = this.ax + delta - this.bx = this.bx + delta - if this.parent_menu then - this.parent_menu:set_offset_x( - offset - ((this.width + this.parent_menu.width) / 2) - - this.item_spacing) - else - update_proximities() - end - end, - fadeout = function(this, callback) - this:tween(1, 0, function(this, pos) - this.opacity = pos - this:set_parent_opacity(pos * config.menu_parent_opacity) - end, callback) - end, - set_parent_opacity = function(this, opacity) - if this.parent_menu then - this.parent_menu.opacity = opacity - this.parent_menu:set_parent_opacity( - opacity * config.menu_parent_opacity) - end - end, - get_item_index_below_cursor = function(this) - return math.ceil((cursor.y - this.ay + this.scroll_y) / - this.scroll_step) - end, - get_first_visible_index = function(this) - return round(this.scroll_y / this.scroll_step) + 1 - end, - get_last_visible_index = function(this) - return round((this.scroll_y + this.height) / this.scroll_step) - end, - get_centermost_visible_index = function(this) - return round((this.scroll_y + (this.height / 2)) / this.scroll_step) - end, - scroll_to = function(this, pos) - this.scroll_y = math.max(math.min(pos, this.scroll_height), 0) - request_render() - end, - scroll_to_item = function(this, index) - if (index and index >= 1 and index <= #this.items) then - this:scroll_to(round((this.scroll_step * (index - 1)) - - ((this.height - this.scroll_step) / 2))) - end - end, - select_index = function(this, index) - this.selected_item = - (index and index >= 1 and index <= #this.items) and index or nil - request_render() - end, - select_value = function(this, value) - this:select_index(itable_find(this.items, function(_, item) - return item.value == value - end)) - end, - activate_index = function(this, index) - this.active_item = - (index and index >= 1 and index <= #this.items) and index or nil - request_render() - end, - activate_value = function(this, value) - this:activate_index(itable_find(this.items, function(_, item) - return item.value == value - end)) - end, - delete_index = function(this, index) - if (index and index >= 1 and index <= #this.items) then - local previous_active_value = - this.active_index and this.items[this.active_index].value or - nil - table.remove(this.items, index) - this:on_display_resize() - if previous_active_value then - this:activate_value(previous_active_value) - end - this:scroll_to_item(this.selected_item) - end - end, - delete_value = function(this, value) - this:delete_index(itable_find(this.items, function(_, item) - return item.value == value - end)) - end, - prev = function(this) - local default_anchor = this.scroll_height > this.scroll_step and - this:get_centermost_visible_index() or - this:get_last_visible_index() - local current_index = this.selected_item or default_anchor + 1 - this.selected_item = math.max(current_index - 1, 1) - this:scroll_to_item(this.selected_item) - end, - next = function(this) - local default_anchor = this.scroll_height > this.scroll_step and - this:get_centermost_visible_index() or - this:get_first_visible_index() - local current_index = this.selected_item or default_anchor - 1 - this.selected_item = math.min(current_index + 1, #this.items) - this:scroll_to_item(this.selected_item) - end, - back = function(this) - if menu.transition then - local transition_target = menu.transition.target - local transition_target_type = menu.transition.target - tween_element_stop(transition_target) - if transition_target_type == 'parent' then - elements:add('menu', transition_target) - end - menu.transition = nil - transition_target:back() - return - else - menu.transition = {to = 'parent', target = this.parent_menu} - end - - if menu.transition.target == nil then - menu:close() - return - end - - local target = menu.transition.target - local to_offset = -target.offset_x + this.offset_x - - tween_element(target, 0, 1, function(_, pos) - this:set_offset_x(round(to_offset * pos)) - this.opacity = 1 - pos - this:set_parent_opacity(config.menu_parent_opacity + - ((1 - config.menu_parent_opacity) * - pos)) - end, function() - menu.transition = nil - elements:add('menu', target) - update_proximities() - end) - end, - open_selected_item = function(this) - -- If there is a transition active and this method got called, it - -- means we are animating from this menu to parent menu, and all - -- calls to this method should be relayed to the parent menu. - if menu.transition and menu.transition.to == 'parent' then - local target = menu.transition.target - tween_element_stop(target) - menu.transition = nil - target:open_selected_item() - return - end - - if this.selected_item then - local item = this.items[this.selected_item] - -- Is submenu - if item.items then - local opts = table_copy(opts) - opts.parent_menu = this - menu:open(item.items, this.open_item, opts) - else - menu:close(true) - this.open_item(item.value) - end - end - end, - close = function(this) menu:close() end, - on_global_mbtn_left_down = function(this) - if this.proximity_raw == 0 then - this.selected_item = this:get_item_index_below_cursor() - this:open_selected_item() - else - -- check if this is clicking on any parent menus - local parent_menu = this.parent_menu - repeat - if parent_menu then - if get_point_to_rectangle_proximity(cursor, parent_menu) == - 0 then - this:back() - return - end - parent_menu = parent_menu.parent_menu - end - until parent_menu == nil - - menu:close() - end - end, - on_global_mouse_move = function(this) - if this.proximity_raw == 0 then - this.selected_item = this:get_item_index_below_cursor() - else - if this.selected_item then - this.selected_item = nil - end - end - request_render() - end, - on_wheel_up = function(this) - this.selected_item = nil - this:scroll_to(this.scroll_y - this.scroll_step) - -- Selects item below cursor - this:on_global_mouse_move() - request_render() - end, - on_wheel_down = function(this) - this.selected_item = nil - this:scroll_to(this.scroll_y + this.scroll_step) - -- Selects item below cursor - this:on_global_mouse_move() - request_render() - end, - on_pgup = function(this) - this.selected_item = nil - this:scroll_to(this.scroll_y - this.height) - end, - on_pgdwn = function(this) - this.selected_item = nil - this:scroll_to(this.scroll_y + this.height) - end, - on_home = function(this) - this.selected_item = nil - this:scroll_to(0) - end, - on_end = function(this) - this.selected_item = nil - this:scroll_to(this.scroll_height) - end, - render = render_menu - })) - - elements.menu:maybe('on_open') -end - -function Menu:add_key_binding(key, name, fn, flags) - menu.key_bindings[#menu.key_bindings + 1] = name - mp.add_forced_key_binding(key, name, fn, flags) -end - -function Menu:enable_key_bindings() - menu.key_bindings = {} - -- The `mp.set_key_bindings()` method would be easier here, but that - -- doesn't support 'repeatable' flag, so we are stuck with this monster. - menu:add_key_binding('up', 'menu-prev', self:create_action('prev'), - 'repeatable') - menu:add_key_binding('down', 'menu-next', self:create_action('next'), - 'repeatable') - menu:add_key_binding('left', 'menu-back', self:create_action('back')) - menu:add_key_binding('right', 'menu-select', - self:create_action('open_selected_item')) - - if options.menu_wasd_navigation then - menu:add_key_binding('w', 'menu-prev-alt', self:create_action('prev'), - 'repeatable') - menu:add_key_binding('a', 'menu-back-alt', self:create_action('back')) - menu:add_key_binding('s', 'menu-next-alt', self:create_action('next'), - 'repeatable') - menu:add_key_binding('d', 'menu-select-alt', - self:create_action('open_selected_item')) - end - - if options.menu_hjkl_navigation then - menu:add_key_binding('h', 'menu-back-alt2', self:create_action('back')) - menu:add_key_binding('j', 'menu-next-alt2', self:create_action('next'), - 'repeatable') - menu:add_key_binding('k', 'menu-prev-alt2', self:create_action('prev'), - 'repeatable') - menu:add_key_binding('l', 'menu-select-alt2', - self:create_action('open_selected_item')) - end - - menu:add_key_binding('mbtn_back', 'menu-back-alt3', - self:create_action('back')) - menu:add_key_binding('bs', 'menu-back-alt4', self:create_action('back')) - menu:add_key_binding('enter', 'menu-select-alt3', - self:create_action('open_selected_item')) - menu:add_key_binding('kp_enter', 'menu-select-alt4', - self:create_action('open_selected_item')) - menu:add_key_binding('esc', 'menu-close', self:create_action('close')) - menu:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup')) - menu:add_key_binding('pgdwn', 'menu-page-down', - self:create_action('on_pgdwn')) - menu:add_key_binding('home', 'menu-home', self:create_action('on_home')) - menu:add_key_binding('end', 'menu-end', self:create_action('on_end')) -end - -function Menu:disable_key_bindings() - for _, name in ipairs(menu.key_bindings) do mp.remove_key_binding(name) end - menu.key_bindings = {} -end - -function Menu:create_action(name) - return function(...) - if elements.menu then elements.menu:maybe(name, ...) end - end -end - -function Menu:close(immediate, callback) - if type(immediate) ~= 'boolean' then callback = immediate end - - if elements:has('menu') and not menu.is_closing then - function close() - elements.menu:maybe('on_close') - elements.menu:destroy() - elements:remove('menu') - menu.is_closing = false - update_proximities() - menu:disable_key_bindings() - call_me_maybe(callback) - end - - menu.is_closing = true - elements.curtain:fadeout() - - if immediate then - close() - else - elements.menu:fadeout(close) - end - end -end - --- ICONS ---[[ -ASS \shadN shadows are drawn also below the element, which when there is an -opacity in play, blends icon colors into ugly greys. The mess below is an -attempt to fix it by rendering shadows for icons with clipping. - -Add icons by adding functions to render them to `icons` table. - -Signature: function(pos_x, pos_y, size) => string - -Function has to return ass path coordinates to draw the icon centered at pox_x -and pos_y of passed size. -]] -local icons = {} -function icon(name, icon_x, icon_y, icon_size, shad_x, shad_y, shad_size, - backdrop, opacity, clip) - local ass = assdraw.ass_new() - local icon_path = icons[name](icon_x, icon_y, icon_size) - local icon_color = options['color_' .. backdrop .. '_text'] - local shad_color = options['color_' .. backdrop] - local use_border = (shad_x + shad_y) == 0 - local icon_border = use_border and shad_size or 0 - - -- clip can't clip out shadows, a very annoying limitation I can't work - -- around without going back to ugly default ass shadows, but atm I actually - -- don't need clipping of icons with shadows, so I'm choosing to ignore this - if not clip then clip = '' end - - if not use_border then - ass:new_event() - ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. shad_color .. '\\iclip(' .. - ass.scale .. ', ' .. icon_path .. ')}') - ass:append(ass_opacity(opacity)) - ass:pos(shad_x + shad_size, shad_y + shad_size) - ass:draw_start() - ass:append(icon_path) - ass:draw_stop() - end - - ass:new_event() - ass:append( - '{\\blur0\\bord' .. icon_border .. '\\shad0\\1c&H' .. icon_color .. - '\\3c&H' .. shad_color .. clip .. '}') - ass:append(ass_opacity(opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:append(icon_path) - ass:draw_stop() - - return ass.text -end - -function icons._volume(muted, pos_x, pos_y, size) - local ass = assdraw.ass_new() - local scale = size / 200 - function x(number) return pos_x + (number * scale) end - function y(number) return pos_y + (number * scale) end - ass:move_to(x(-85), y(-35)) - ass:line_to(x(-50), y(-35)) - ass:line_to(x(-5), y(-75)) - ass:line_to(x(-5), y(75)) - ass:line_to(x(-50), y(35)) - ass:line_to(x(-85), y(35)) - if muted then - ass:move_to(x(76), y(-35)) - ass:line_to(x(50), y(-9)) - ass:line_to(x(24), y(-35)) - ass:line_to(x(15), y(-26)) - ass:line_to(x(41), y(0)) - ass:line_to(x(15), y(26)) - ass:line_to(x(24), y(35)) - ass:line_to(x(50), y(9)) - ass:line_to(x(76), y(35)) - ass:line_to(x(85), y(26)) - ass:line_to(x(59), y(0)) - ass:line_to(x(85), y(-26)) - else - ass:move_to(x(20), y(-30)) - ass:line_to(x(20), y(30)) - ass:line_to(x(35), y(30)) - ass:line_to(x(35), y(-30)) - - ass:move_to(x(55), y(-60)) - ass:line_to(x(55), y(60)) - ass:line_to(x(70), y(60)) - ass:line_to(x(70), y(-60)) - end - return ass.text -end -function icons.volume(pos_x, pos_y, size) - return icons._volume(false, pos_x, pos_y, size) -end -function icons.volume_muted(pos_x, pos_y, size) - return icons._volume(true, pos_x, pos_y, size) -end - -function icons.arrow_right(pos_x, pos_y, size) - local ass = assdraw.ass_new() - local scale = size / 200 - function x(number) return pos_x + (number * scale) end - function y(number) return pos_y + (number * scale) end - ass:move_to(x(-22), y(-80)) - ass:line_to(x(-45), y(-57)) - ass:line_to(x(12), y(0)) - ass:line_to(x(-45), y(57)) - ass:line_to(x(-22), y(80)) - ass:line_to(x(58), y(0)) - return ass.text -end - --- STATE UPDATES +--[[ STATE UPDATERS ]] function update_display_dimensions() - local o = mp.get_property_native('osd-dimensions') - display.width = o.w - display.height = o.h - display.aspect = o.aspect + local scale = (state.hidpi_scale or 1) * options.ui_scale + local real_width, real_height = mp.get_osd_size() + if real_width <= 0 then return end + local scaled_width, scaled_height = round(real_width / scale), round(real_height / scale) + display.width, display.height = scaled_width, scaled_height + display.scale_x, display.scale_y = real_width / scaled_width, real_height / scaled_height + display.initialized = true - -- Tell elements about this - for _, element in elements:ipairs() do - if element.on_display_resize ~= nil then - element.on_display_resize(element) - end - end + -- Tell elements about this + Elements:trigger('display') + + -- Some elements probably changed their rectangles as a reaction to `display` + Elements:update_proximities() + request_render() end -function update_element_cursor_proximity(element) - if cursor.hidden then - element.proximity_raw = infinity - element.proximity = 0 - else - local range = options.proximity_out - options.proximity_in - element.proximity_raw = - get_point_to_rectangle_proximity(cursor, element) - element.proximity = menu:is_open() and 0 or 1 - - (math.min( - math.max( - element.proximity_raw - - options.proximity_in, 0), range) / - range) - end +function update_fullormaxed() + state.fullormaxed = state.fullscreen or state.maximized + update_display_dimensions() + Elements:trigger('prop_fullormaxed', state.fullormaxed) + update_cursor_position(INFINITY, INFINITY) end -function update_proximities() - local capture_mouse_buttons = false - local capture_wheel = false - local menu_only = menu:is_open() - local mouse_left_elements = {} - local mouse_entered_elements = {} - - -- Calculates proximities and opacities for defined elements - for _, element in elements:ipairs() do - local previous_proximity_raw = element.proximity_raw - - -- If menu is open, all other elements have to be disabled - if menu_only then - if element.name == 'menu' then - capture_mouse_buttons = true - capture_wheel = true - update_element_cursor_proximity(element) - else - element.proximity_raw = infinity - element.proximity = 0 - end - else - update_element_cursor_proximity(element) - end - - if element.proximity_raw == 0 then - -- Mouse is over element - if element.captures and element.captures.mouse_buttons then - capture_mouse_buttons = true - end - if element.captures and element.captures.wheel then - capture_wheel = true - end - - -- Mouse entered element area - if previous_proximity_raw ~= 0 then - mouse_entered_elements[#mouse_entered_elements + 1] = element - end - else - -- Mouse left element area - if previous_proximity_raw == 0 then - mouse_left_elements[#mouse_left_elements + 1] = element - end - end - end - - -- Enable key group captures elements request. - if capture_mouse_buttons then - forced_key_bindings.mouse_buttons:enable() - else - forced_key_bindings.mouse_buttons:disable() - end - if capture_wheel then - forced_key_bindings.wheel:enable() - else - forced_key_bindings.wheel:disable() - end - - -- Trigger `mouse_leave` and `mouse_enter` events - for _, element in ipairs(mouse_left_elements) do - element:trigger('mouse_leave') - end - for _, element in ipairs(mouse_entered_elements) do - element:trigger('mouse_enter') - end +function update_human_times() + if state.time then + state.time_human = format_time(state.time, state.duration) + if state.duration then + local speed = state.speed or 1 + if options.destination_time == 'playtime-remaining' then + state.destination_time_human = format_time((state.time - state.duration) / speed, state.duration) + elseif options.destination_time == 'total' then + state.destination_time_human = format_time(state.duration, state.duration) + else + state.destination_time_human = format_time(state.time - state.duration, state.duration) + end + else + state.destination_time_human = nil + end + else + state.time_human = nil + end end --- ELEMENT RENDERERS +-- Notifies other scripts such as console about where the unoccupied parts of the screen are. +function update_margins() + if display.height == 0 then return end -function render_timeline(this) - if this.size_max == 0 or state.duration == nil or state.position == nil then - return - end + local function is_persistent(element) return element and element.enabled and element:is_persistent() end + local timeline, top_bar, controls, volume = Elements.timeline, Elements.top_bar, Elements.controls, Elements.volume + -- margins are normalized to window size + local left, right, top, bottom = 0, 0, 0, 0 - local size_min = this:get_effective_size_min() - local size = this:get_effective_size() + if is_persistent(controls) then bottom = (display.height - controls.ay) / display.height + elseif is_persistent(timeline) then bottom = (display.height - timeline.ay) / display.height end - if size < 1 then return end + if is_persistent(top_bar) then top = top_bar.title_by / display.height end - local ass = assdraw.ass_new() + if is_persistent(volume) then + if options.volume == 'left' then left = volume.bx / display.width + elseif options.volume == 'right' then right = volume.ax / display.width end + end - -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min - local hide_text_below = math.max(this.font_size * 0.7, size_min * 2) - local hide_text_ramp = hide_text_below / 2 - local text_opacity = math.max(math.min(size - hide_text_below, - hide_text_ramp), 0) / hide_text_ramp + if top == state.margin_top and bottom == state.margin_bottom and + left == state.margin_left and right == state.margin_right then return end - local spacing = math.max(math.floor((this.size_max - this.font_size) / 2.5), - 4) - local progress = state.position / state.duration + state.margin_top = top + state.margin_bottom = bottom + state.margin_left = left + state.margin_right = right - -- Background bar coordinates - local bax = 0 - local bay = display.height - size - this.bottom_border - this.top_border - local bbx = display.width - local bby = display.height + utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom)) + mp.set_property_native('user-data/osc/margins', { l = left, r = right, t = top, b = bottom }) - -- Foreground bar coordinates - local fax = bax - local fay = bay + this.top_border - local fbx = bbx * progress - local fby = bby - this.bottom_border - local foreground_size = bby - bay - local foreground_coordinates = fax .. ',' .. fay .. ',' .. fbx .. ',' .. fby -- for clipping - - -- Background - ass:new_event() - ass:append( - '{\\blur0\\bord0\\1c&H' .. options.color_background .. '\\iclip(' .. - foreground_coordinates .. ')}') - ass:append(ass_opacity(math.max(options.timeline_opacity - 0.1, 0))) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(bax, bay, bbx, bby) - ass:draw_stop() - - -- Foreground - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}') - ass:append(ass_opacity(options.timeline_opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(fax, fay, fbx, fby) - ass:draw_stop() - - -- Seekable ranges - if options.timeline_cached_ranges and state.cached_ranges then - local range_height = math.max(foreground_size / 8, size_min) - local range_ay = fby - range_height - for _, range in ipairs(state.cached_ranges) do - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. - options.timeline_cached_ranges.color .. '}') - ass:append(ass_opacity(options.timeline_cached_ranges.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(bbx * (range['start'] / state.duration), range_ay, - bbx * (range['end'] / state.duration), - range_ay + range_height) - ass:draw_stop() - end - end - - -- Custom ranges - if state.chapter_ranges ~= nil then - for i, chapter_range in ipairs(state.chapter_ranges) do - for i, range in ipairs(chapter_range.ranges) do - local rax = display.width * - (range['start'].time / state.duration) - local rbx = display.width * (range['end'].time / state.duration) - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. chapter_range.color .. '}') - ass:append(ass_opacity(chapter_range.opacity)) - ass:pos(0, 0) - ass:draw_start() - -- for 1px chapter size, use the whole size of the bar including padding - if size <= 1 then - ass:rect_cw(rax, bay, rbx, bby) - else - ass:rect_cw(rax, fay, rbx, fby) - end - ass:draw_stop() - end - end - end - - -- Chapters - if options.chapters ~= 'none' and state.chapters ~= nil and #state.chapters > - 0 then - local half_size = size / 2 - local dots = false - local chapter_size, chapter_y - if options.chapters == 'dots' then - dots = true - chapter_size = math.min(6, (foreground_size / 2) + 2) - chapter_y = math.min(fay + chapter_size, fay + half_size) - elseif options.chapters == 'lines' then - chapter_size = size - chapter_y = fay + (chapter_size / 2) - elseif options.chapters == 'lines-top' then - chapter_size = math.min(this.size_max / 3.5, size) - chapter_y = fay + (chapter_size / 2) - elseif options.chapters == 'lines-bottom' then - chapter_size = math.min(this.size_max / 3.5, size) - chapter_y = fay + size - (chapter_size / 2) - end - - if chapter_size ~= nil then - -- for 1px chapter size, use the whole size of the bar including padding - chapter_size = size <= 1 and foreground_size or chapter_size - local chapter_half_size = chapter_size / 2 - - for i, chapter in ipairs(state.chapters) do - local chapter_x = display.width * - (chapter.time / state.duration) - local color = chapter_x > fbx and options.color_foreground or - options.color_background - - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. color .. '}') - ass:append(ass_opacity(options.chapters_opacity)) - ass:pos(0, 0) - ass:draw_start() - - if dots then - local bezier_stretch = chapter_size * 0.67 - ass:move_to(chapter_x - chapter_half_size, chapter_y) - ass:bezier_curve(chapter_x - chapter_half_size, - chapter_y - bezier_stretch, - chapter_x + chapter_half_size, - chapter_y - bezier_stretch, - chapter_x + chapter_half_size, chapter_y) - ass:bezier_curve(chapter_x + chapter_half_size, - chapter_y + bezier_stretch, - chapter_x - chapter_half_size, - chapter_y + bezier_stretch, - chapter_x - chapter_half_size, chapter_y) - else - ass:rect_cw(chapter_x, chapter_y - chapter_half_size, - chapter_x + 1, chapter_y + chapter_half_size) - end - - ass:draw_stop() - end - end - end - - if text_opacity > 0 then - -- Elapsed time - if state.elapsed_seconds then - ass:new_event() - ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. - options.color_foreground_text .. '\\fn' .. - config.font .. '\\fs' .. this.font_size .. bold_tag .. - '\\clip(' .. foreground_coordinates .. ')') - ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), - text_opacity)) - ass:pos(spacing, fay + (size / 2)) - ass:an(4) - ass:append(state.elapsed_time) - ass:new_event() - ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. - options.color_background_text .. '\\4c&H' .. - options.color_background .. '\\fn' .. config.font .. - '\\fs' .. this.font_size .. bold_tag .. '\\iclip(' .. - foreground_coordinates .. ')') - ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), - text_opacity)) - ass:pos(spacing, fay + (size / 2)) - ass:an(4) - ass:append(state.elapsed_time) - end - - -- Remaining time - if state.remaining_seconds then - ass:new_event() - ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. - options.color_foreground_text .. '\\fn' .. - config.font .. '\\fs' .. this.font_size .. bold_tag .. - '\\clip(' .. foreground_coordinates .. ')') - ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), - text_opacity)) - ass:pos(display.width - spacing, fay + (size / 2)) - ass:an(6) - ass:append('-' .. state.remaining_time) - ass:new_event() - ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. - options.color_background_text .. '\\4c&H' .. - options.color_background .. '\\fn' .. config.font .. - '\\fs' .. this.font_size .. bold_tag .. '\\iclip(' .. - foreground_coordinates .. ')') - ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), - text_opacity)) - ass:pos(display.width - spacing, fay + (size / 2)) - ass:an(6) - ass:append('-' .. state.remaining_time) - end - end - - if (this.proximity_raw == 0 or this.pressed) and - not (elements.speed and elements.speed.dragging) then - -- Hovered time - local hovered_seconds = state.duration * (cursor.x / display.width) - local box_half_width_guesstimate = (this.font_size * 4.2) / 2 - ass:new_event() - ass:append('{\\blur0\\bord1\\shad0\\1c&H' .. - options.color_background_text .. '\\3c&H' .. - options.color_background .. '\\fn' .. config.font .. - '\\fs' .. this.font_size .. bold_tag .. '') - ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1))) - ass:pos(math.min(math.max(cursor.x, box_half_width_guesstimate), - display.width - box_half_width_guesstimate), fay) - ass:an(2) - ass:append(mp.format_time(hovered_seconds)) - - -- Cursor line - ass:new_event() - ass:append('{\\blur0\\bord0\\xshad-1\\yshad0\\1c&H' .. - options.color_foreground .. '\\4c&H' .. - options.color_background .. '}') - ass:append(ass_opacity(0.2)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(cursor.x, fay, cursor.x + 1, fby) - ass:draw_stop() - end - - return ass + if not options.adjust_osd_margins then return end + local osd_margin_y, osd_margin_x, osd_factor_x = 0, 0, display.width / display.height * 720 + if config.osd_alignment_y == 'bottom' then osd_margin_y = round(bottom * 720) + elseif config.osd_alignment_y == 'top' then osd_margin_y = round(top * 720) end + if config.osd_alignment_x == 'left' then osd_margin_x = round(left * osd_factor_x) + elseif config.osd_alignment_x == 'right' then osd_margin_x = round(right * osd_factor_x) end + mp.set_property_native('osd-margin-y', osd_margin_y + config.osd_margin_y) + mp.set_property_native('osd-margin-x', osd_margin_x + config.osd_margin_x) +end +function create_state_setter(name, callback) + return function(_, value) + set_state(name, value) + if callback then callback() end + request_render() + end end -function render_top_bar(this) - local opacity = this:get_effective_proximity() - - if not this.enabled or opacity == 0 then return end - - local ass = assdraw.ass_new() - - if options.top_bar_controls then - -- Close button - local close = elements.window_controls_close - if close.proximity_raw == 0 then - -- Background on hover - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H2311e8}') - ass:append(ass_opacity(this.button_opacity, opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(close.ax, close.ay, close.bx, close.by) - ass:draw_stop() - end - ass:new_event() - ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}') - ass:append(ass_opacity(this.button_opacity, opacity)) - ass:pos(close.ax + (this.button_width / 2), (this.size / 2)) - ass:draw_start() - ass:move_to(-this.icon_size, this.icon_size) - ass:line_to(this.icon_size, -this.icon_size) - ass:move_to(-this.icon_size, -this.icon_size) - ass:line_to(this.icon_size, this.icon_size) - ass:draw_stop() - - -- Maximize button - local maximize = elements.window_controls_maximize - if maximize.proximity_raw == 0 then - -- Background on hover - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H222222}') - ass:append(ass_opacity(this.button_opacity, opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(maximize.ax, maximize.ay, maximize.bx, maximize.by) - ass:draw_stop() - end - ass:new_event() - ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&H000000}') - ass:append(ass_opacity({[3] = this.button_opacity}, opacity)) - ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2)) - ass:draw_start() - ass:rect_cw(-this.icon_size + 1, -this.icon_size + 1, - this.icon_size + 1, this.icon_size + 1) - ass:draw_stop() - ass:new_event() - ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&HFFFFFF}') - ass:append(ass_opacity({[3] = this.button_opacity}, opacity)) - ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2)) - ass:draw_start() - ass:rect_cw(-this.icon_size, -this.icon_size, this.icon_size, - this.icon_size) - ass:draw_stop() - - -- Minimize button - local minimize = elements.window_controls_minimize - if minimize.proximity_raw == 0 then - -- Background on hover - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H222222}') - ass:append(ass_opacity(this.button_opacity, opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(minimize.ax, minimize.ay, minimize.bx, minimize.by) - ass:draw_stop() - end - ass:new_event() - ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}') - ass:append(ass_opacity(this.button_opacity, opacity)) - ass:append('{\\1a&HFF&}') - ass:pos(minimize.ax + (this.button_width / 2), (this.size / 2)) - ass:draw_start() - ass:move_to(-this.icon_size, 0) - ass:line_to(this.icon_size, 0) - ass:draw_stop() - end - - -- Window title - if options.top_bar_title and state.media_title then - local clip_coordinates = - '0,0,' .. (this.title_bx - this.spacing) .. ',' .. this.size - - ass:new_event() - ass:append('{\\q2\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000\\fn' .. - config.font .. '\\fs' .. this.font_size .. bold_tag .. - '\\clip(' .. clip_coordinates .. ')') - ass:append(ass_opacity(1, opacity)) - ass:pos(0 + this.spacing, this.size / 2) - ass:an(4) - ass:append(state.media_title) - end - - return ass +function set_state(name, value) + state[name] = value + Elements:trigger('prop_' .. name, value) end -function render_volume(this) - local slider = elements.volume_slider - local opacity = this:get_effective_proximity() - - if this.width == 0 or opacity == 0 then return end - - local ass = assdraw.ass_new() - - if slider.height > 0 then - -- Background bar coordinates - local bax = slider.ax - local bay = slider.ay - local bbx = slider.bx - local bby = slider.by - - -- Foreground bar coordinates - local height_without_border = slider.height - - (options.volume_border * 2) - local fax = slider.ax + options.volume_border - local fay = slider.ay + - (height_without_border * - (1 - math.min(state.volume / state.volume_max, 1))) + - options.volume_border - local fbx = slider.bx - options.volume_border - local fby = slider.by - options.volume_border - - -- Path to draw a foreground bar with a 100% volume indicator, already - -- clipped by volume level. Can't just clip it with rectangle, as it itself - -- also needs to be used as a path to clip the background bar and volume - -- number. - local fpath = assdraw.ass_new() - fpath:move_to(fbx, fby) - fpath:line_to(fax, fby) - local nudge_bottom_y = slider.nudge_y + slider.nudge_size - if fay <= nudge_bottom_y and slider.draw_nudge then - fpath:line_to(fax, math.min(nudge_bottom_y)) - if fay <= slider.nudge_y then - fpath:line_to((fax + slider.nudge_size), slider.nudge_y) - local nudge_top_y = slider.nudge_y - slider.nudge_size - if fay <= nudge_top_y then - fpath:line_to(fax, nudge_top_y) - fpath:line_to(fax, fay) - fpath:line_to(fbx, fay) - fpath:line_to(fbx, nudge_top_y) - else - local triangle_side = fay - nudge_top_y - fpath:line_to((fax + triangle_side), fay) - fpath:line_to((fbx - triangle_side), fay) - end - fpath:line_to((fbx - slider.nudge_size), slider.nudge_y) - else - local triangle_side = nudge_bottom_y - fay - fpath:line_to((fax + triangle_side), fay) - fpath:line_to((fbx - triangle_side), fay) - end - fpath:line_to(fbx, nudge_bottom_y) - else - fpath:line_to(fax, fay) - fpath:line_to(fbx, fay) - end - fpath:line_to(fbx, fby) - - -- Background - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. - '\\iclip(' .. fpath.scale .. ', ' .. fpath.text .. ')}') - ass:append(ass_opacity(math.max(options.volume_opacity - 0.1, 0), - opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:move_to(bax, bay) - ass:line_to(bbx, bay) - local half_border = options.volume_border / 2 - if slider.draw_nudge then - ass:line_to(bbx, math.max(slider.nudge_y - slider.nudge_size + - half_border, bay)) - ass:line_to(bbx - slider.nudge_size + half_border, slider.nudge_y) - ass:line_to(bbx, slider.nudge_y + slider.nudge_size - half_border) - end - ass:line_to(bbx, bby) - ass:line_to(bax, bby) - if slider.draw_nudge then - ass:line_to(bax, slider.nudge_y + slider.nudge_size - half_border) - ass:line_to(bax + slider.nudge_size - half_border, slider.nudge_y) - ass:line_to(bax, math.max(slider.nudge_y - slider.nudge_size + - half_border, bay)) - end - ass:line_to(bax, bay) - ass:draw_stop() - - -- Foreground - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}') - ass:append(ass_opacity(options.volume_opacity, opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:append(fpath.text) - ass:draw_stop() - - -- Current volume value - local volume_string = tostring(round(state.volume * 10) / 10) - local font_size = round(((this.width * 0.6) - - (#volume_string * (this.width / 20))) * - options.volume_font_scale) - if fay < slider.by - slider.spacing then - ass:new_event() - ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. - options.color_foreground_text .. '\\fn' .. - config.font .. '\\fs' .. font_size .. bold_tag .. - '\\clip(' .. fpath.scale .. ', ' .. fpath.text .. - ')}') - ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), - opacity)) - ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing) - ass:an(2) - ass:append(volume_string) - end - if fay > slider.by - slider.spacing - font_size then - ass:new_event() - ass:append('{\\blur0\\bord0\\shad1\\1c&H' .. - options.color_background_text .. '\\4c&H' .. - options.color_background .. '\\fn' .. config.font .. - '\\fs' .. font_size .. bold_tag .. '\\iclip(' .. - fpath.scale .. ', ' .. fpath.text .. ')}') - ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), - opacity)) - ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing) - ass:an(2) - ass:append(volume_string) - end - end - - -- Mute button - local mute = elements.volume_mute - local icon_name = state.mute and 'volume_muted' or 'volume' - ass:new_event() - ass:append(icon(icon_name, mute.ax + (mute.width / 2), - mute.ay + (mute.height / 2), mute.width * 0.7, -- x, y, size - 0, 0, options.volume_border, -- shadow_x, shadow_y, shadow_size - 'background', options.volume_opacity * opacity -- backdrop, opacity - )) - return ass -end - -function render_speed(this) - if not this.dragging and (elements.curtain.opacity > 0) then return end - - local timeline = elements.timeline - local proximity = timeline:get_effective_proximity() - local opacity = this.forced_proximity and this.forced_proximity or - (this.dragging and 1 or proximity) - - if opacity == 0 then return end - - local ass = assdraw.ass_new() - - -- Coordinates - local ax = this.ax - local ay = this.ay + timeline.size_max - timeline:get_effective_size() - - timeline.top_border - timeline.bottom_border - local bx = this.bx - local by = ay + this.height - local half_width = (this.width / 2) - local half_x = ax + half_width - - -- Notches - local speed_at_center = state.speed - if this.dragging then - speed_at_center = this.dragging.start_speed + - ((-this.dragging.distance / this.step_distance) * - options.speed_step) - speed_at_center = math.min(math.max(speed_at_center, 0.01), 100) - end - local nearest_notch_speed = round(speed_at_center / this.notch_every) * - this.notch_every - local nearest_notch_x = half_x + - (((nearest_notch_speed - speed_at_center) / - this.notch_every) * this.notch_spacing) - local guide_size = math.floor(this.height / 7.5) - local notch_by = by - guide_size - local notch_ay_big = ay + round(this.font_size * 1.1) - local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2) - local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4) - local from_to_index = math.floor(this.notches / 2) - - for i = -from_to_index, from_to_index do - local notch_speed = nearest_notch_speed + (i * this.notch_every) - - if notch_speed < 0 or notch_speed > 100 then goto continue end - - local notch_x = nearest_notch_x + (i * this.notch_spacing) - local notch_thickness = 1 - local notch_ay = notch_ay_small - if (notch_speed % (this.notch_every * 10)) < 0.00000001 then - notch_ay = notch_ay_big - notch_thickness = 1 - elseif (notch_speed % (this.notch_every * 5)) < 0.00000001 then - notch_ay = notch_ay_medium - end - - ass:new_event() - ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}') - ass:append(ass_opacity(math.min(1.2 - - (math.abs( - (notch_x - ax - half_width) / - half_width)), 1), opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:move_to(notch_x - notch_thickness, notch_ay) - ass:line_to(notch_x + notch_thickness, notch_ay) - ass:line_to(notch_x + notch_thickness, notch_by) - ass:line_to(notch_x - notch_thickness, notch_by) - ass:draw_stop() - - ::continue:: - end - - -- Center guide - ass:new_event() - ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}') - ass:append(ass_opacity(options.speed_opacity, opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:move_to(half_x, by - 2 - guide_size) - ass:line_to(half_x + guide_size, by - 2) - ass:line_to(half_x - guide_size, by - 2) - ass:draw_stop() - - -- Speed value - local speed_text = (round(state.speed * 100) / 100) .. 'x' - ass:new_event() - ass:append( - '{\\blur0\\bord1\\shad0\\1c&H' .. options.color_background_text .. - '\\3c&H' .. options.color_background .. '\\fn' .. config.font .. - '\\fs' .. this.font_size .. bold_tag .. '}') - ass:append(ass_opacity(options.speed_opacity, opacity)) - ass:pos(half_x, ay) - ass:an(8) - ass:append(speed_text) - - return ass -end - -function render_menu(this) - local ass = assdraw.ass_new() - - if this.parent_menu then ass:merge(this.parent_menu:render()) end - - -- Menu title - if this.title then - -- Background - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}') - ass:append(ass_opacity(options.menu_opacity, this.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(this.ax, this.ay - this.item_height, this.bx, this.ay - 1) - ass:draw_stop() - - -- Title - ass:new_event() - ass:append('{\\blur0\\bord0\\shad1\\b1\\1c&H' .. - options.color_background_text .. '\\4c&H' .. - options.color_background .. '\\fn' .. config.font .. - '\\fs' .. this.font_size .. '\\q2\\clip(' .. this.ax .. - ',' .. this.ay - this.item_height .. ',' .. this.bx .. - ',' .. this.ay .. ')}') - ass:append(ass_opacity(options.menu_opacity, this.opacity)) - ass:pos(display.width / 2, this.ay - (this.item_height * 0.5)) - ass:an(5) - ass:append(this.title) - end - - local scroll_area_clip = '\\clip(' .. this.ax .. ',' .. this.ay .. ',' .. - this.bx .. ',' .. this.by .. ')' - - for index, item in ipairs(this.items) do - local item_ay = this.ay - this.scroll_y + - (this.item_height * (index - 1) + this.item_spacing * - (index - 1)) - local item_by = item_ay + this.item_height - local item_clip = '' - - -- Clip items overflowing scroll area - if item_ay <= this.ay or item_by >= this.by then - item_clip = scroll_area_clip - end - - if item_by < this.ay or item_ay > this.by then goto continue end - - local is_active = this.active_item == index - local font_color, background_color, ass_shadow, ass_shadow_color - local icon_size = this.font_size - - if is_active then - font_color, background_color = options.color_foreground_text, - options.color_foreground - ass_shadow, ass_shadow_color = '\\shad0', '' - else - font_color, background_color = options.color_background_text, - options.color_background - ass_shadow, ass_shadow_color = '\\shad1', - '\\4c&H' .. background_color - end - - local has_submenu = item.items ~= nil - local hint_width = 0 - if item.hint then - hint_width = text_width_estimate(item.hint:len(), this.font_size) + - this.item_content_spacing - elseif has_submenu then - hint_width = icon_size + this.item_content_spacing - end - - -- Background - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. background_color .. item_clip .. - '}') - ass:append(ass_opacity(options.menu_opacity, this.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(this.ax, item_ay, this.bx, item_by) - ass:draw_stop() - - -- Selected highlight - if this.selected_item == index then - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. - item_clip .. '}') - ass:append(ass_opacity(0.1, this.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(this.ax, item_ay, this.bx, item_by) - ass:draw_stop() - end - - -- Title - if item.title then - item.ass_save_title = item.ass_save_title or - item.title:gsub("([{}])", "\\%1") - local title_clip_x = (this.bx - hint_width - - this.item_content_spacing) - local title_clip = '\\clip(' .. this.ax .. ',' .. - math.max(item_ay, this.ay) .. ',' .. - title_clip_x .. ',' .. - math.min(item_by, this.by) .. ')' - ass:new_event() - ass:append( - '{\\blur0\\bord0\\shad1\\1c&H' .. font_color .. '\\4c&H' .. - background_color .. '\\fn' .. config.font .. '\\fs' .. - this.font_size .. bold_tag .. title_clip .. '\\q2}') - ass:append(ass_opacity(options.menu_opacity, this.opacity)) - ass:pos(this.ax + this.item_content_spacing, - item_ay + (this.item_height / 2)) - ass:an(4) - ass:append(item.ass_save_title) - end - - -- Hint - if item.hint then - item.ass_save_hint = item.ass_save_hint or - item.hint:gsub("([{}])", "\\%1") - ass:new_event() - ass:append( - '{\\blur0\\bord0' .. ass_shadow .. '\\1c&H' .. font_color .. '' .. - ass_shadow_color .. '\\fn' .. config.font .. '\\fs' .. - (this.font_size - 1) .. bold_tag .. item_clip .. '}') - ass:append(ass_opacity(options.menu_opacity * - (has_submenu and 1 or 0.5), this.opacity)) - ass:pos(this.bx - this.item_content_spacing, - item_ay + (this.item_height / 2)) - ass:an(6) - ass:append(item.ass_save_hint) - elseif has_submenu then - ass:new_event() - ass:append(icon('arrow_right', - this.bx - this.item_content_spacing - - (icon_size / 2), -- x - item_ay + (this.item_height / 2), -- y - icon_size, -- size - 0, 0, 1, -- shadow_x, shadow_y, shadow_size - is_active and 'foreground' or 'background', this.opacity, -- backdrop, opacity - item_clip)) - end - - ::continue:: - end - - -- Scrollbar - if this.scroll_height > 0 then - local groove_height = this.height - 2 - local thumb_height = math.max((this.height / - (this.scroll_height + this.height)) * - groove_height, 40) - local thumb_y = this.ay + 1 + - ((this.scroll_y / this.scroll_height) * - (groove_height - thumb_height)) - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}') - ass:append(ass_opacity(options.menu_opacity, this.opacity * 0.8)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(this.bx - 3, thumb_y, this.bx - 1, thumb_y + thumb_height) - ass:draw_stop() - end - - return ass -end - --- MAIN RENDERING - --- Request that render() is called. --- The render is then either executed immediately, or rate-limited if it was --- called a small time ago. -function request_render() - if state.render_timer == nil then - state.render_timer = mp.add_timeout(0, render) - end - - if not state.render_timer:is_enabled() then - local now = mp.get_time() - local timeout = config.render_delay - (now - state.render_last_time) - if timeout < 0 then timeout = 0 end - state.render_timer.timeout = timeout - state.render_timer:resume() - end -end - -function render() - state.render_last_time = mp.get_time() - - -- Actual rendering - local ass = assdraw.ass_new() - - for _, element in elements.ipairs() do - local result = element:maybe('render') - if result then - ass:new_event() - ass:merge(result) - end - end - - -- submit - if osd.res_x == display.width and osd.res_y == display.height and osd.data == - ass.text then return end - - osd.res_x = display.width - osd.res_y = display.height - osd.data = ass.text - osd.z = 2000 - osd:update() -end - --- STATIC ELEMENTS - -if itable_find({'flash', 'static'}, options.pause_indicator) then - elements:add('pause_indicator', Element.new( - { - base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8, - paused = false, - is_flash = options.pause_indicator == 'flash', - is_static = options.pause_indicator == 'static', - opacity = 0, - init = function(this) - local initial_call = true - mp.observe_property('pause', 'bool', function(_, paused) - if initial_call then - initial_call = false - return - end - - this.paused = paused - - if options.pause_indicator == 'flash' then - this.opacity = 1 - this:tween_property('opacity', 1, 0, 0.15) - else - this.opacity = paused and 1 or 0 - request_render() - end - - end) - end, - render = function(this) - if this.opacity == 0 then return end - - local ass = assdraw.ass_new() - - -- Background fadeout - if this.is_static then - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. - options.color_background .. '}') - ass:append(ass_opacity(0.3, this.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(0, 0, display.width, display.height) - ass:draw_stop() - end - - -- Icon - local size = round((math.min(display.width, display.height) * - (this.is_static and 0.20 or 0.15)) / 2) - - size = size + size * (1 - this.opacity) - - if this.paused then - ass:new_event() - ass:append('{\\blur0\\bord1\\1c&H' .. - options.color_foreground .. '\\3c&H' .. - options.color_background .. '}') - ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) - ass:pos(display.width / 2, display.height / 2) - ass:draw_start() - ass:rect_cw(-size, -size, -size / 3, size) - ass:draw_stop() - - ass:new_event() - ass:append('{\\blur0\\bord1\\1c&H' .. - options.color_foreground .. '\\3c&H' .. - options.color_background .. '}') - ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) - ass:pos(display.width / 2, display.height / 2) - ass:draw_start() - ass:rect_cw(size / 3, -size, size, size) - ass:draw_stop() - elseif this.is_flash then - ass:new_event() - ass:append('{\\blur0\\bord1\\1c&H' .. - options.color_foreground .. '\\3c&H' .. - options.color_background .. '}') - ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) - ass:pos(display.width / 2, display.height / 2) - ass:draw_start() - ass:move_to(-size * 0.6, -size) - ass:line_to(size, 0) - ass:line_to(-size * 0.6, size) - ass:draw_stop() - end - - return ass - end - })) -end -elements:add('timeline', Element.new({ - captures = {mouse_buttons = true, wheel = true}, - pressed = false, - size_max = 0, - size_min = 0, -- set in `on_display_resize` handler based on `state.fullscreen` - size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command - font_size = 0, -- calculated in on_display_resize - top_border = options.timeline_border, - bottom_border = 0, -- set dynamically in `border` property observer - init = function(this) - -- Toggle 1px bottom border for timeline in no-border mode - mp.observe_property('border', 'bool', function(_, border) - this.bottom_border = not border and options.timeline_border or 0 - request_render() - end) - - -- Flash on external changes - if options.timeline_flash then - mp.register_event('seek', function() - local position = mp.get_property_native('playback-time') - if position and state.position then - local seek_length = math.abs(position - state.position) - -- Don't flash on video looping (seek to 0) or tiny seeks (frame-step) - if position > 0.5 and seek_length > 0.5 then - this:flash() - end - end - end) - end - end, - get_effective_proximity = function(this) - if (elements.volume_slider and elements.volume_slider.pressed) then - return 0 - end - if this.pressed then return 1 end - return this.forced_proximity and this.forced_proximity or this.proximity - end, - get_effective_size_min = function(this) - return this.size_min_override or this.size_min - end, - get_effective_size = function(this) - if elements.speed and elements.speed.dragging then - return this.size_max - end - local size_min = this:get_effective_size_min() - return size_min + - math.ceil((this.size_max - size_min) * - this:get_effective_proximity()) - end, - on_display_resize = function(this) - if state.fullscreen or state.maximized then - this.size_min = options.timeline_size_min_fullscreen - this.size_max = options.timeline_size_max_fullscreen - else - this.size_min = options.timeline_size_min - this.size_max = options.timeline_size_max - end - this.font_size = math.floor(math.min((this.size_max + 60) * 0.2, - this.size_max * 0.96) * - options.timeline_font_scale) - this.ax = 0 - this.ay = display.height - this.size_max - this.top_border - - this.bottom_border - this.bx = display.width - this.by = display.height - end, - set_from_cursor = function(this) - mp.commandv('seek', ((cursor.x / display.width) * 100), - 'absolute-percent+exact') - end, - on_mbtn_left_down = function(this) - this.pressed = true - this:set_from_cursor() - end, - on_global_mbtn_left_up = function(this) this.pressed = false end, - on_global_mouse_leave = function(this) this.pressed = false end, - on_global_mouse_move = function(this) - if this.pressed then this:set_from_cursor() end - end, - on_wheel_up = function(this) - if options.timeline_step > 0 then - mp.commandv('seek', -options.timeline_step) - end - end, - on_wheel_down = function(this) - if options.timeline_step > 0 then - mp.commandv('seek', options.timeline_step) - end - end, - render = render_timeline -})) -if options.top_bar_controls or options.top_bar_title then - elements:add('top_bar', Element.new({ - button_opacity = 0.8, - enabled = false, - init = function(this) - mp.observe_property('border', 'bool', function(_, border) - this.enabled = not border - end) - end, - get_effective_proximity = function(this) - if (elements.volume_slider and elements.volume_slider.pressed) or - elements.curtain.opacity > 0 then return 0 end - return this.forced_proximity and this.forced_proximity or - this.proximity - end, - on_display_resize = function(this) - this.size = (state.fullscreen or state.maximized) and - options.top_bar_size_fullscreen or - options.top_bar_size - this.icon_size = round(this.size / 8) - this.spacing = math.ceil(this.size * 0.25) - this.font_size = math.floor(this.size - (this.spacing * 2)) - this.button_width = round(this.size * 1.15) - this.title_bx = display.width - - (options.top_bar_controls and - (this.button_width * 3) or 0) - this.ax = options.top_bar_title and 0 or this.title_bx - this.ay = 0 - this.bx = display.width - this.by = this.size - end, - render = render_top_bar - })) -end -if options.top_bar_controls then - elements:add('window_controls_minimize', Element.new( - { - captures = {mouse_buttons = true}, - on_display_resize = function(this) - this.ax = display.width - (elements.top_bar.button_width * 3) - this.ay = 0 - this.bx = this.ax + elements.top_bar.button_width - this.by = elements.top_bar.size - end, - on_mbtn_left_down = function() - mp.commandv('cycle', 'window-minimized') - end - })) - elements:add('window_controls_maximize', Element.new( - { - captures = {mouse_buttons = true}, - on_display_resize = function(this) - this.ax = display.width - (elements.top_bar.button_width * 2) - this.ay = 0 - this.bx = this.ax + elements.top_bar.button_width - this.by = elements.top_bar.size - end, - on_mbtn_left_down = function() - mp.commandv('cycle', 'window-maximized') - end - })) - elements:add('window_controls_close', Element.new( - { - captures = {mouse_buttons = true}, - on_display_resize = function(this) - this.ax = display.width - elements.top_bar.button_width - this.ay = 0 - this.bx = this.ax + elements.top_bar.button_width - this.by = elements.top_bar.size - end, - on_mbtn_left_down = function() mp.commandv('quit') end - })) -end -if itable_find({'left', 'right'}, options.volume) then - elements:add('volume', Element.new({ - width = nil, -- set in `on_display_resize` handler based on `state.fullscreen` - height = nil, -- set in `on_display_resize` handler based on `state.fullscreen` - margin = nil, -- set in `on_display_resize` handler based on `state.fullscreen` - init = function(this) - -- FLash on external changes - if options.volume_flash then - local is_initial_volume_call = true - mp.observe_property('volume', 'number', function(_, value) - if not is_initial_volume_call then - this:flash() - end - is_initial_volume_call = false - end) - local is_initial_mute_call = true - mp.observe_property('mute', 'bool', function(_, value) - if not is_initial_mute_call then - this:flash() - end - is_initial_mute_call = false - end) - end - end, - get_effective_proximity = function(this) - if elements.volume_slider.pressed then return 1 end - if elements.timeline.proximity_raw == 0 or elements.curtain.opacity > - 0 then return 0 end - return this.forced_proximity and this.forced_proximity or - this.proximity - end, - on_display_resize = function(this) - this.width = (state.fullscreen or state.maximized) and - options.volume_size_fullscreen or - options.volume_size - this.height = round(math.min(this.width * 8, (elements.timeline.ay - - elements.top_bar.size) * 0.8)) - -- Don't bother rendering this if too small - if this.height < (this.width * 2) then this.height = 0 end - this.margin = this.width / 2 - this.ax = round(options.volume == 'left' and this.margin or - display.width - this.margin - this.width) - this.ay = round((display.height - this.height) / 2) - this.bx = round(this.ax + this.width) - this.by = round(this.ay + this.height) - end, - render = render_volume - })) - elements:add('volume_mute', Element.new( - { - captures = {mouse_buttons = true}, - width = 0, - height = 0, - on_display_resize = function(this) - this.width = elements.volume.width - this.height = this.width - this.ax = elements.volume.ax - this.ay = elements.volume.by - this.height - this.bx = elements.volume.bx - this.by = elements.volume.by - end, - on_mbtn_left_down = function(this) - mp.commandv('cycle', 'mute') - end - })) - elements:add('volume_slider', Element.new( - { - captures = {mouse_buttons = true, wheel = true}, - pressed = false, - width = 0, - height = 0, - nudge_y = 0, -- vertical position where volume overflows 100 - nudge_size = nil, -- set on resize - font_size = nil, - spacing = nil, - on_display_resize = function(this) - this.ax = elements.volume.ax - this.ay = elements.volume.ay - this.bx = elements.volume.bx - this.by = elements.volume_mute.ay - this.width = this.bx - this.ax - this.height = this.by - this.ay - this.nudge_y = this.by - - round(this.height * (100 / state.volume_max)) - this.nudge_size = round(elements.volume.width * 0.18) - this.draw_nudge = this.ay < this.nudge_y - this.spacing = round(this.width * 0.2) - end, - set_from_cursor = function(this) - local volume_fraction = (this.by - cursor.y - - options.volume_border) / - (this.height - options.volume_border) - local new_volume = math.min(math.max(volume_fraction, 0), 1) * - state.volume_max - new_volume = round(new_volume / options.volume_step) * - options.volume_step - if state.volume ~= new_volume then - mp.commandv('set', 'volume', - math.min(new_volume, state.volume_max)) - end - end, - on_mbtn_left_down = function(this) - this.pressed = true - this:set_from_cursor() - end, - on_global_mbtn_left_up = function(this) - this.pressed = false - end, - on_global_mouse_leave = function(this) - this.pressed = false - end, - on_global_mouse_move = function(this) - if this.pressed then this:set_from_cursor() end - end, - on_wheel_up = function(this) - local current_rounded_volume = - round(state.volume / options.volume_step) * - options.volume_step - mp.commandv('set', 'volume', math.min( - current_rounded_volume + options.volume_step, - state.volume_max)) - end, - on_wheel_down = function(this) - local current_rounded_volume = - round(state.volume / options.volume_step) * - options.volume_step - mp.commandv('set', 'volume', math.min( - current_rounded_volume - options.volume_step, - state.volume_max)) - end - })) -end -if options.speed then - elements:add('speed', Element.new({ - captures = {mouse_buttons = true, wheel = true}, - dragging = nil, - width = 0, - height = 0, - notches = 10, - notch_every = 0.1, - step_distance = nil, - font_size = nil, - init = function(this) - -- Fade out/in on timeline mouse enter/leave - elements.timeline:on('mouse_enter', function() - if not this.dragging then this:fadeout() end - end) - elements.timeline:on('mouse_leave', function() - if not this.dragging then this:fadein() end - end) - - -- Flash on external changes - if options.speed_flash then - local initial_call = true - mp.observe_property('speed', 'number', function() - if not initial_call and not this.dragging then - this:flash() - end - initial_call = false - end) - end - end, - fadeout = function(this) - this:tween_property('forced_proximity', 1, 0, - function(this) - this.forced_proximity = 0 - end) - end, - fadein = function(this) - local get_current_proximity = - function() return this.proximity end - this:tween_property('forced_proximity', 0, get_current_proximity, - function(this) - this.forced_proximity = nil - end) - end, - on_display_resize = function(this) - this.height = (state.fullscreen or state.maximized) and - options.speed_size_fullscreen or - options.speed_size - this.width = round(this.height * 3.6) - this.notch_spacing = this.width / this.notches - this.step_distance = this.notch_spacing * - (options.speed_step / this.notch_every) - this.ax = (display.width - this.width) / 2 - this.by = display.height - elements.timeline.size_max - this.ay = this.by - this.height - this.bx = this.ax + this.width - this.font_size = - round(this.height * 0.48 * options.speed_font_scale) - end, - set_from_cursor = function(this) - local volume_fraction = - (this.by - cursor.y - options.volume_border) / - (this.height - options.volume_border) - local new_volume = math.min(math.max(volume_fraction, 0), 1) * - state.volume_max - new_volume = round(new_volume / options.volume_step) * - options.volume_step - if state.volume ~= new_volume then - mp.commandv('set', 'volume', new_volume) - end - end, - on_mbtn_left_down = function(this) - this:tween_stop() -- Stop and cleanup possible ongoing animations - this.dragging = { - start_time = mp.get_time(), - start_x = cursor.x, - distance = 0, - start_speed = state.speed - } - end, - on_global_mouse_move = function(this) - if not this.dragging then return end - - this.dragging.distance = cursor.x - this.dragging.start_x - local steps_dragged = round(-this.dragging.distance / - this.step_distance) - local new_speed = this.dragging.start_speed + - (steps_dragged * options.speed_step) - mp.set_property_native('speed', round(new_speed * 100) / 100) - end, - on_mbtn_left_up = function(this) - -- Reset speed on short clicks - if this.dragging and math.abs(this.dragging.distance) < 6 and - mp.get_time() - this.dragging.start_time < 0.15 then - mp.set_property_native('speed', 1) - end - end, - on_global_mbtn_left_up = function(this) - if this.dragging and elements.timeline.proximity_raw == 0 then - this:fadeout() - end - this.dragging = nil - request_render() - end, - on_global_mouse_leave = function(this) - this.dragging = nil - request_render() - end, - on_wheel_up = function(this) - mp.set_property_native('speed', state.speed - options.speed_step) - end, - on_wheel_down = function(this) - mp.set_property_native('speed', state.speed + options.speed_step) - end, - render = render_speed - })) -end -elements:add('curtain', Element.new({ - opacity = 0, - fadeout = function(this) this:tween_property('opacity', this.opacity, 0); end, - fadein = function(this) this:tween_property('opacity', this.opacity, 1); end, - render = function(this) - if this.opacity > 0 then - local ass = assdraw.ass_new() - ass:new_event() - ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. - '}') - ass:append(ass_opacity(0.4, this.opacity)) - ass:pos(0, 0) - ass:draw_start() - ass:rect_cw(0, 0, display.width, display.height) - ass:draw_stop() - return ass - end - end -})) - --- CHAPTERS SERIALIZATION - --- Parse `chapter_ranges` option into workable data structure -for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do - local start_patterns, color, opacity, end_patterns = - string.match(definition, '([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)') - - -- Invalid definition - if start_patterns == nil then goto continue end - - start_patterns = start_patterns:lower() - end_patterns = end_patterns:lower() - local uses_bof = start_patterns:find('{bof}') ~= nil - local uses_eof = end_patterns:find('{eof}') ~= nil - local chapter_range = { - start_patterns = split(start_patterns, '|'), - end_patterns = split(end_patterns, '|'), - color = color, - opacity = tonumber(opacity), - ranges = {} - } - - -- Filter out special keywords so we don't use them when matching titles - if uses_bof then - chapter_range.start_patterns = itable_remove( - chapter_range.start_patterns, '{bof}') - end - if uses_eof and chapter_range.end_patterns then - chapter_range.end_patterns = itable_remove(chapter_range.end_patterns, - '{eof}') - end - - chapter_range['serialize'] = function(chapters) - chapter_range.ranges = {} - local current_range = nil - -- bof and eof should be used only once per timeline - -- eof is only used when last range is missing end - local bof_used = false - - function start_range(chapter) - -- If there is already a range started, should we append or overwrite? - -- I chose overwrite here. - current_range = {['start'] = chapter} - end - - function end_range(chapter) - current_range['end'] = chapter - chapter_range.ranges[#chapter_range.ranges + 1] = current_range - -- Mark both chapter objects - current_range['start']._uosc_used_as_range_point = true - current_range['end']._uosc_used_as_range_point = true - -- Clear for next range - current_range = nil - end - - for _, chapter in ipairs(chapters) do - if type(chapter.title) == 'string' then - local lowercase_title = chapter.title:lower() - local is_end = false - local is_start = false - - -- Is ending check and handling - if chapter_range.end_patterns then - for _, end_pattern in ipairs(chapter_range.end_patterns) do - is_end = is_end or lowercase_title:find(end_pattern) ~= - nil - end - - if is_end then - if current_range == nil and uses_bof and not bof_used then - bof_used = true - start_range({time = 0}) - end - if current_range ~= nil then - end_range(chapter) - else - is_end = false - end - end - end - - -- Is start check and handling - for _, start_pattern in ipairs(chapter_range.start_patterns) do - is_start = - is_start or lowercase_title:find(start_pattern) ~= nil - end - - if is_start then start_range(chapter) end - end - end - - -- If there is an unfinished range and range type accepts eof, use it - if current_range ~= nil and uses_eof then - end_range({time = state.duration or infinity}) - end - end - - state.chapter_ranges = state.chapter_ranges or {} - state.chapter_ranges[#state.chapter_ranges + 1] = chapter_range - - ::continue:: -end - -function parse_chapters() - -- Sometimes state.duration is not initialized yet for some reason - state.duration = mp.get_property_native('duration') - - local chapters = get_normalized_chapters() - - if not chapters or not state.duration then return end - - -- Reset custom ranges - for _, chapter_range in ipairs(state.chapter_ranges or {}) do - chapter_range.serialize(chapters) - end - - -- Filter out chapters that were used as ranges - state.chapters = itable_remove(chapters, function(chapter) - return chapter._uosc_used_as_range_point == true - end) - - request_render() -end - --- CONTEXT MENU SERIALIZATION - -state.context_menu_items = (function() - local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'}) - local input_conf_meta, meta_error = utils.file_info(input_conf_path) - - -- File doesn't exist - if not input_conf_meta or not input_conf_meta.is_file then return end - - local items = {} - local items_by_command = {} - local submenus_by_id = {} - - for line in io.lines(input_conf_path) do - local key, command, title = string.match(line, - ' *([%S]+) +(.*) #! *(.*)') - if key then - local is_dummy = key:sub(1, 1) == '#' - local submenu_id = '' - local target_menu = items - local title_parts = split(title or '', ' *> *') - - for index, title_part in ipairs( - #title_parts > 0 and title_parts or - {''}) do - if index < #title_parts then - submenu_id = submenu_id .. title_part - - if not submenus_by_id[submenu_id] then - submenus_by_id[submenu_id] = - {title = title_part, items = {}} - target_menu[#target_menu + 1] = - submenus_by_id[submenu_id] - end - - target_menu = submenus_by_id[submenu_id].items - else - -- If command is already in menu, just append the key to it - if items_by_command[command] then - items_by_command[command].hint = - items_by_command[command].hint .. ', ' .. key - else - items_by_command[command] = - { - title = title_part, - hint = not is_dummy and key or nil, - value = command - } - target_menu[#target_menu + 1] = - items_by_command[command] - end - end - end - end - end - - if #items > 0 then return items end -end)() - --- EVENT HANDLERS - -function create_state_setter(name) - return function(_, value) - state[name] = value - dispatch_event_to_elements('prop_' .. name, value) - request_render() - end -end - -function dispatch_event_to_elements(name, ...) - for _, element in pairs(elements) do - if element.proximity_raw == 0 then - element:maybe('on_' .. name, ...) - end - element:maybe('on_global_' .. name, ...) - end -end - -function create_event_to_elements_dispatcher(name, ...) - return function(...) dispatch_event_to_elements(name, ...) end +function update_cursor_position(x, y) + local old_x, old_y = cursor.x, cursor.y + + -- mpv reports initial mouse position on linux as (0, 0), which always + -- displays the top bar, so we hardcode cursor position as infinity until + -- we receive a first real mouse move event with coordinates other than 0,0. + if not state.first_real_mouse_move_received then + if x > 0 and y > 0 then state.first_real_mouse_move_received = true + else x, y = INFINITY, INFINITY end + end + + -- add 0.5 to be in the middle of the pixel + cursor.x, cursor.y = (x + 0.5) / display.scale_x, (y + 0.5) / display.scale_y + + if old_x ~= cursor.x or old_y ~= cursor.y then + Elements:update_proximities() + + if cursor.x == INFINITY or cursor.y == INFINITY then + cursor.hidden = true + Elements:trigger('global_mouse_leave') + elseif cursor.hidden then + cursor.hidden = false + Elements:trigger('global_mouse_enter') + end + + Elements:proximity_trigger('mouse_move') + cursor.queue_autohide() + end + + request_render() end function handle_mouse_leave() - -- Slowly fadeout elements that are currently visible - for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do - local element = elements[element_name] - if element and element.proximity > 0 then - element:tween_property('forced_proximity', - element:get_effective_proximity(), 0, - function() - element.forced_proximity = nil - end) - end - end + -- Slowly fadeout elements that are currently visible + for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do + local element = Elements[element_name] + if element and element.proximity > 0 then + element:tween_property('forced_visibility', element:get_visibility(), 0, function() + element.forced_visibility = nil + end) + end + end - cursor.hidden = true - update_proximities() - dispatch_event_to_elements('mouse_leave') + update_cursor_position(INFINITY, INFINITY) end -function handle_mouse_enter() - cursor.hidden = false - cursor.x, cursor.y = mp.get_mouse_pos() - tween_element_stop(state) - dispatch_event_to_elements('mouse_enter') +function handle_file_end() + local resume = false + if not state.loop_file then + if state.has_playlist then resume = state.shuffle and navigate_playlist(1) + else resume = options.autoload and navigate_directory(1) end + end + -- Resume only when navigation happened + if resume then mp.command('set pause no') end +end +local file_end_timer = mp.add_timeout(1, handle_file_end) +file_end_timer:kill() + +function load_file_index_in_current_directory(index) + if not state.path or is_protocol(state.path) then return end + + local serialized = serialize_path(state.path) + if serialized and serialized.dirname then + local files = read_directory(serialized.dirname, config.types.autoload) + + if not files then return end + sort_filenames(files) + if index < 0 then index = #files + index + 1 end + + if files[index] then + mp.commandv('loadfile', join_path(serialized.dirname, files[index])) + end + end end -function handle_mouse_move() - -- Handle case when we are in cursor hidden state but not left the actual - -- window (i.e. when autohide simulates mouse_leave). - if cursor.hidden then - handle_mouse_enter() - return - end - - cursor.x, cursor.y = mp.get_mouse_pos() - update_proximities() - dispatch_event_to_elements('mouse_move') - request_render() - - -- Restart timer that hides UI when mouse is autohidden - if options.autohide then - state.cursor_autohide_timer:kill() - state.cursor_autohide_timer:resume() - end +function update_render_delay(name, fps) + if fps then state.render_delay = 1 / fps end end -function navigate_directory(direction) - local path = mp.get_property_native("path") - - if not path or is_protocol(path) then return end - - local next_file = get_adjacent_file(path, direction, options.media_types) - - if next_file then - mp.commandv("loadfile", - utils.join_path(serialize_path(path).dirname, next_file)) - end +function observe_display_fps(name, fps) + if fps then + mp.unobserve_property(update_render_delay) + mp.unobserve_property(observe_display_fps) + mp.observe_property('display-fps', 'native', update_render_delay) + end end -function load_file_in_current_directory(index) - local path = mp.get_property_native("path") - - if not path or is_protocol(path) then return end - - local dirname = serialize_path(path).dirname - local files = get_files_in_directory(dirname, options.media_types) - - if not files then return end - if index < 0 then index = #files + index + 1 end - - if files[index] then - mp.commandv("loadfile", utils.join_path(dirname, files[index])) - end +function select_current_chapter() + local current_chapter + if state.time and state.chapters then + _, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, true) + end + set_state('current_chapter', current_chapter) end --- MENUS +--[[ STATE HOOKS ]] -function create_select_tracklist_type_menu_opener(menu_title, track_type, - track_prop) - return function() - if menu:is_open(track_type) then - menu:close() - return - end - - local items = {} - local active_item = nil - - for index, track in ipairs(mp.get_property_native('track-list')) do - if track.type == track_type then - if track.selected then active_item = track.id end - - items[#items + 1] = { - title = (track.title and track.title or 'Track ' .. track.id), - hint = track.lang and track.lang:upper() or nil, - value = track.id - } - end - end - - -- Add option to disable a subtitle track. This works for all tracks, - -- but why would anyone want to disable audio or video? Better to not - -- let people mistakenly select what is unwanted 99.999% of the time. - -- If I'm mistaken and there is an active need for this, feel free to - -- open an issue. - if track_type == 'sub' then - active_item = active_item and active_item + 1 or 1 - table.insert(items, 1, {hint = 'disabled', value = nil}) - end - - menu:open(items, function(id) - mp.commandv('set', track_prop, id and id or 'no') - - -- If subtitle track was selected, assume user also wants to see it - if id and track_type == 'sub' then - mp.commandv('set', 'sub-visibility', 'yes') - end - - menu:close() - end, {type = track_type, title = menu_title, active_item = active_item}) - end +-- Click detection +if options.click_threshold > 0 then + -- Executes custom command for clicks shorter than `options.click_threshold` + -- while filtering out double clicks. + local click_time = options.click_threshold / 1000 + local doubleclick_time = mp.get_property_native('input-doubleclick-time') / 1000 + local last_down, last_up = 0, 0 + local click_timer = mp.add_timeout(math.max(click_time, doubleclick_time), function() + local delta = last_up - last_down + if delta > 0 and delta < click_time and delta > 0.02 then mp.command(options.click_command) end + end) + click_timer:kill() + mp.set_key_bindings({{'mbtn_left', + function() last_up = mp.get_time() end, + function() + last_down = mp.get_time() + if click_timer:is_enabled() then click_timer:kill() else click_timer:resume() end + end, + },}, 'mouse_movement', 'force') + mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor') end --- `menu_options`: --- **allowed_types** - table with file extensions to display --- **active_path** - full path of a file to preselect --- Rest of the options are passed to `menu:open()` -function open_file_navigation_menu(directory, handle_select, menu_options) - directory = serialize_path(directory) - local directories, error = utils.readdir(directory.path, 'dirs') - local files, error = get_files_in_directory(directory.path, - menu_options.allowed_types) - - if not files or not directories then - msg.error('Retrieving files from ' .. directory .. ' failed: ' .. - (error or '')) - return - end - - -- Files are already sorted - table.sort(directories, word_order_comparator) - - -- Pre-populate items with parent directory selector if not at root - local items = not directory.dirname and {} or - { - {title = '..', hint = 'parent dir', value = directory.dirname} - } - - for _, dir in ipairs(directories) do - local serialized = serialize_path(utils.join_path(directory.path, dir)) - items[#items + 1] = { - title = serialized.basename, - value = serialized.path, - hint = '/' - } - end - - menu_options.active_item = nil - - for _, file in ipairs(files) do - local serialized = serialize_path(utils.join_path(directory.path, file)) - local item_index = #items + 1 - - items[item_index] = { - title = serialized.basename, - value = serialized.path - } - - if menu_options.active_path == serialized.path then - menu_options.active_item = item_index - end - end - - menu_options.title = directory.basename .. '/' - - menu:open(items, function(path) - local meta, error = utils.file_info(path) - - if not meta then - msg.error('Retrieving file info for ' .. path .. ' failed: ' .. - (error or '')) - return - end - - if meta.is_dir then - open_file_navigation_menu(path, handle_select, menu_options) - else - handle_select(path) - menu:close() - end - end, menu_options) +function handle_mouse_pos(_, mouse) + if not mouse then return end + if cursor.hover_raw and not mouse.hover then + handle_mouse_leave() + else + update_cursor_position(mouse.x, mouse.y) + end + cursor.hover_raw = mouse.hover end +mp.observe_property('mouse-pos', 'native', handle_mouse_pos) +mp.observe_property('osc', 'bool', function(name, value) if value == true then mp.set_property('osc', 'no') end end) +mp.register_event('file-loaded', function() + set_state('path', normalize_path(mp.get_property_native('path'))) + Elements:flash({'top_bar'}) +end) +mp.register_event('end-file', function(event) + set_state('path', nil) + if event.reason == 'eof' then + file_end_timer:kill() + handle_file_end() + end +end) +-- Top bar titles +do + local function update_state_with_template(prop, template) + -- escape ASS, and strip newlines and trailing slashes and trim whitespace + local tmp = mp.command_native({'expand-text', template}):gsub('\\n', ' '):gsub('[\\%s]+$', ''):gsub('^%s+', '') + set_state(prop, ass_escape(tmp)) + end --- VALUE SERIALIZATION/NORMALIZATION + local function add_template_listener(template, callback) + local props = get_expansion_props(template) + for prop, _ in pairs(props) do + mp.observe_property(prop, 'native', callback) + end + if not next(props) then callback() end + end -options.proximity_out = - math.max(options.proximity_out, options.proximity_in + 1) -options.chapters = itable_find({'dots', 'lines', 'lines-top', 'lines-bottom'}, - options.chapters) and options.chapters or 'none' -options.media_types = split(options.media_types, ' *, *') -options.subtitle_types = split(options.subtitle_types, ' *, *') -options.timeline_cached_ranges = (function() - if options.timeline_cached_ranges == '' or options.timeline_cached_ranges == - 'no' then return nil end - local parts = split(options.timeline_cached_ranges, ':') - return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil -end)() + local function remove_template_listener(callback) mp.unobserve_property(callback) end --- HOOKS -mp.register_event('file-loaded', parse_chapters) -mp.observe_property('chapter-list', 'native', parse_chapters) -mp.observe_property('duration', 'number', create_state_setter('duration')) -mp.observe_property('media-title', 'string', create_state_setter('media_title')) -mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen')) -mp.observe_property('window-maximized', 'bool', create_state_setter('maximized')) -mp.observe_property('idle-active', 'bool', create_state_setter('idle')) -mp.observe_property('speed', 'number', create_state_setter('speed')) -mp.observe_property('pause', 'bool', create_state_setter('pause')) + -- Main title + if #options.top_bar_title > 0 and options.top_bar_title ~= 'no' then + if options.top_bar_title == 'yes' then + local template = nil + local function update_title() update_state_with_template('title', template) end + mp.observe_property('title', 'string', function(_, title) + remove_template_listener(update_title) + template = title + if template then + if template:sub(-6) == ' - mpv' then template = template:sub(1, -7) end + add_template_listener(template, update_title) + end + end) + elseif type(options.top_bar_title) == 'string' then + add_template_listener(options.top_bar_title, function() + update_state_with_template('title', options.top_bar_title) + end) + end + end + + -- Alt title + if #options.top_bar_alt_title > 0 and options.top_bar_alt_title ~= 'no' then + add_template_listener(options.top_bar_alt_title, function() + update_state_with_template('alt_title', options.top_bar_alt_title) + end) + end +end +mp.observe_property('playback-time', 'number', create_state_setter('time', function() + -- Create a file-end event that triggers right before file ends + file_end_timer:kill() + if state.duration and state.time and not state.pause then + local remaining = (state.duration - state.time) / state.speed + if remaining < 5 then + local timeout = remaining - 0.02 + if timeout > 0 then + file_end_timer.timeout = timeout + file_end_timer:resume() + else handle_file_end() end + end + end + + update_human_times() + select_current_chapter() +end)) +mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times)) +mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times)) +mp.observe_property('track-list', 'native', function(name, value) + -- checks the file dispositions + local types = {sub = 0, image = 0, audio = 0, video = 0} + for _, track in ipairs(value) do + if track.type == 'video' then + if track.image or track.albumart then types.image = types.image + 1 + else types.video = types.video + 1 end + elseif types[track.type] then types[track.type] = types[track.type] + 1 end + end + set_state('is_audio', types.video == 0 and types.audio > 0) + set_state('is_image', types.image > 0 and types.video == 0 and types.audio == 0) + set_state('has_audio', types.audio > 0) + set_state('has_many_audio', types.audio > 1) + set_state('has_sub', types.sub > 0) + set_state('has_many_sub', types.sub > 1) + set_state('is_video', types.video > 0) + set_state('has_many_video', types.video > 1) + Elements:trigger('dispositions') +end) +mp.observe_property('editions', 'number', function(_, editions) + if editions then set_state('has_many_edition', editions > 1) end + Elements:trigger('dispositions') +end) +mp.observe_property('chapter-list', 'native', function(_, chapters) + local chapters, chapter_ranges = serialize_chapters(chapters), {} + if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end + set_state('chapters', chapters) + set_state('chapter_ranges', chapter_ranges) + set_state('has_chapter', #chapters > 0) + select_current_chapter() + Elements:trigger('dispositions') +end) +mp.observe_property('border', 'bool', create_state_setter('border')) +mp.observe_property('loop-file', 'native', create_state_setter('loop_file')) +mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a')) +mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b')) +mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos')) +mp.observe_property('playlist-count', 'number', function(_, value) + set_state('playlist_count', value) + set_state('has_playlist', value > 1) + Elements:trigger('dispositions') +end) +mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed)) +mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed)) +mp.observe_property('idle-active', 'bool', function(_, idle) + set_state('is_idle', idle) + Elements:trigger('dispositions') +end) +mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end)) mp.observe_property('volume', 'number', create_state_setter('volume')) mp.observe_property('volume-max', 'number', create_state_setter('volume_max')) mp.observe_property('mute', 'bool', create_state_setter('mute')) -mp.observe_property('playback-time', 'number', function(name, val) - -- Ignore the initial call with nil value - if val == nil then return end - - state.position = val - state.elapsed_seconds = val - state.elapsed_time = state.elapsed_seconds and - mp.format_time(state.elapsed_seconds) or nil - state.remaining_seconds = mp.get_property_native('playtime-remaining') - state.remaining_time = state.remaining_seconds and - mp.format_time(state.remaining_seconds) or nil - - request_render() -end) mp.observe_property('osd-dimensions', 'native', function(name, val) - update_display_dimensions() - request_render() + update_display_dimensions() + request_render() end) +mp.observe_property('display-hidpi-scale', 'native', create_state_setter('hidpi_scale', update_display_dimensions)) +mp.observe_property('cache', 'string', create_state_setter('cache')) +mp.observe_property('cache-buffering-state', 'number', create_state_setter('cache_buffering')) +mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function() + Elements:trigger('dispositions') +end)) mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state) - if cache_state == nil then - state.cached_ranges = nil - return - end - local cache_ranges = cache_state['seekable-ranges'] - state.cached_ranges = #cache_ranges > 0 and cache_ranges or nil + local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil + if cache_state then + cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached'] + set_state('cache_underrun', cache_state['underrun']) + else cached_ranges = {} end + + if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or + (state.cache == 'auto' and state.is_stream))) then + if state.uncached_ranges then set_state('uncached_ranges', nil) end + return + end + + -- Normalize + local ranges = {} + for _, range in ipairs(cached_ranges) do + ranges[#ranges + 1] = { + math.max(range['start'] or 0, 0), + math.min(range['end'] or state.duration, state.duration), + } + end + table.sort(ranges, function(a, b) return a[1] < b[1] end) + if bof then ranges[1][1] = 0 end + if eof then ranges[#ranges][2] = state.duration end + -- Invert cached ranges into uncached ranges, as that's what we're rendering + local inverted_ranges = {{0, state.duration}} + for _, cached in pairs(ranges) do + inverted_ranges[#inverted_ranges][2] = cached[1] + inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration} + end + uncached_ranges = {} + local last_range = nil + for _, range in ipairs(inverted_ranges) do + if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges + last_range[2] = range[2] + else + if range[2] - range[1] > 0.5 then -- skip short ranges + uncached_ranges[#uncached_ranges + 1] = range + last_range = range + end + end + end + + set_state('uncached_ranges', uncached_ranges) end) +mp.observe_property('display-fps', 'native', observe_display_fps) +mp.observe_property('estimated-display-fps', 'native', update_render_delay) +mp.observe_property('eof-reached', 'native', create_state_setter('eof_reached')) +mp.observe_property('core-idle', 'native', create_state_setter('core_idle')) --- CONTROLS +--[[ KEY BINDS ]] --- Mouse movement key binds -local base_keybinds = { - {'mouse_move', handle_mouse_move}, {'mouse_leave', handle_mouse_leave}, - {'mouse_enter', handle_mouse_enter} -} -if options.pause_on_click_shorter_than > 0 then - -- Cycles pause when click is shorter than `options.pause_on_click_shorter_than` - -- while filtering out double clicks. - local duration_seconds = options.pause_on_click_shorter_than / 1000 - local last_down_event; - local click_timer = mp.add_timeout(duration_seconds, - function() mp.command('cycle pause') end); - click_timer:kill() - base_keybinds[#base_keybinds + 1] = { - 'mbtn_left', function() - if mp.get_time() - last_down_event < duration_seconds then - click_timer:resume() - end - end, function() - if click_timer:is_enabled() then - click_timer:kill() - last_down_event = 0 - else - last_down_event = mp.get_time() - end - end - } +-- Pointer related binding groups +function make_cursor_handler(event, cb) + return function(...) + call_maybe(cursor[event], ...) + call_maybe(cb, ...) + cursor.queue_autohide() -- refresh cursor autohide timer + end end -mp.set_key_bindings(base_keybinds, 'mouse_movement', 'force') -mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor') +mp.set_key_bindings({ + { + 'mbtn_left', + make_cursor_handler('on_primary_up'), + make_cursor_handler('on_primary_down', function(...) + handle_mouse_pos(nil, mp.get_property_native('mouse-pos')) + end), + }, + {'mbtn_left_dbl', 'ignore'}, +}, 'mbtn_left', 'force') +mp.set_key_bindings({ + {'wheel_up', make_cursor_handler('on_wheel_up')}, + {'wheel_down', make_cursor_handler('on_wheel_down')}, +}, 'wheel', 'force') --- Context based key bind groups +-- Adds a key binding that respects rerouting set by `key_binding_overwrites` table. +---@param name string +---@param callback fun(event: table) +---@param flags nil|string +function bind_command(name, callback, flags) + mp.add_key_binding(nil, name, function(...) + if key_binding_overwrites[name] then mp.command(key_binding_overwrites[name]) + else callback(...) end + end, flags) +end -forced_key_bindings = (function() - mp.set_key_bindings({ - { - 'mbtn_left', create_event_to_elements_dispatcher('mbtn_left_up'), - create_event_to_elements_dispatcher('mbtn_left_down') - }, {'mbtn_left_dbl', 'ignore'} - }, 'mouse_buttons', 'force') - mp.set_key_bindings({ - {'wheel_up', create_event_to_elements_dispatcher('wheel_up')}, - {'wheel_down', create_event_to_elements_dispatcher('wheel_down')} - }, 'wheel', 'force') - - local groups = {} - for _, group in ipairs({'mouse_buttons', 'wheel'}) do - groups[group] = { - is_enabled = false, - enable = function(this) - if this.is_enabled then return end - this.is_enabled = true - mp.enable_key_bindings(group) - end, - disable = function(this) - if not this.is_enabled then return end - this.is_enabled = false - mp.disable_key_bindings(group) - end - } - end - return groups -end)() - --- KEY BINDABLE FEATURES - -mp.add_key_binding(nil, 'peek-timeline', function() - if elements.timeline.proximity > 0.5 then - elements.timeline:tween_property('proximity', - elements.timeline.proximity, 0) - else - elements.timeline:tween_property('proximity', - elements.timeline.proximity, 1) - end +bind_command('toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end) +bind_command('flash-ui', function() Elements:flash({'timeline', 'controls', 'volume', 'top_bar'}) end) +bind_command('flash-timeline', function() Elements:flash({'timeline'}) end) +bind_command('flash-top-bar', function() Elements:flash({'top_bar'}) end) +bind_command('flash-volume', function() Elements:flash({'volume'}) end) +bind_command('flash-speed', function() Elements:flash({'speed'}) end) +bind_command('flash-pause-indicator', function() Elements:flash({'pause_indicator'}) end) +bind_command('toggle-progress', function() + local timeline = Elements.timeline + if timeline.size_min_override then + timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function() + timeline.size_min_override = nil + end) + else + timeline:tween_property('size_min_override', timeline.size_min, 0) + end end) -mp.add_key_binding(nil, 'toggle-progress', function() - local timeline = elements.timeline - if timeline.size_min_override then - timeline:tween_property('size_min_override', timeline.size_min_override, - timeline.size_min, - function() - timeline.size_min_override = nil - end) - else - timeline:tween_property('size_min_override', timeline.size_min, 0) - end +bind_command('toggle-title', function() Elements.top_bar:toggle_title() end) +bind_command('decide-pause-indicator', function() Elements.pause_indicator:decide() end) +bind_command('menu', function() toggle_menu_with_items() end) +bind_command('menu-blurred', function() toggle_menu_with_items({mouse_nav = true}) end) +local track_loaders = { + {name = 'subtitles', prop = 'sub', allowed_types = itable_join(config.types.video, config.types.subtitle)}, + {name = 'audio', prop = 'audio', allowed_types = itable_join(config.types.video, config.types.audio)}, + {name = 'video', prop = 'video', allowed_types = config.types.video}, +} +for _, loader in ipairs(track_loaders) do + local menu_type = 'load-' .. loader.name + bind_command(menu_type, function() + if Menu:is_open(menu_type) then Menu:close() return end + + local path = state.path + if path then + if is_protocol(path) then + path = false + else + local serialized_path = serialize_path(path) + path = serialized_path ~= nil and serialized_path.dirname or false + end + end + if not path then + path = get_default_directory() + end + open_file_navigation_menu( + path, + function(path) mp.commandv(loader.prop .. '-add', path) end, + {type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types} + ) + end) +end +bind_command('subtitles', create_select_tracklist_type_menu_opener( + 'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles' +)) +bind_command('audio', create_select_tracklist_type_menu_opener( + 'Audio', 'audio', 'aid', 'script-binding uosc/load-audio' +)) +bind_command('video', create_select_tracklist_type_menu_opener( + 'Video', 'video', 'vid', 'script-binding uosc/load-video' +)) +bind_command('playlist', create_self_updating_menu_opener({ + title = 'Playlist', + type = 'playlist', + list_prop = 'playlist', + serializer = function(playlist) + local items = {} + for index, item in ipairs(playlist) do + local is_url = item.filename:find('://') + local item_title = type(item.title) == 'string' and #item.title > 0 and item.title or false + items[index] = { + title = item_title or (is_url and item.filename or serialize_path(item.filename).basename), + hint = tostring(index), + active = item.current, + value = index, + } + end + return items + end, + on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end, + on_move_item = function(from, to) + mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1)) + end, + on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end, +})) +bind_command('chapters', create_self_updating_menu_opener({ + title = 'Chapters', + type = 'chapters', + list_prop = 'chapter-list', + active_prop = 'chapter', + serializer = function(chapters, current_chapter) + local items = {} + chapters = normalize_chapters(chapters) + for index, chapter in ipairs(chapters) do + items[index] = { + title = chapter.title or '', + hint = format_time(chapter.time, state.duration), + value = index, + active = index - 1 == current_chapter, + } + end + return items + end, + on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end, +})) +bind_command('editions', create_self_updating_menu_opener({ + title = 'Editions', + type = 'editions', + list_prop = 'edition-list', + active_prop = 'current-edition', + serializer = function(editions, current_id) + local items = {} + for _, edition in ipairs(editions or {}) do + items[#items + 1] = { + title = edition.title or 'Edition', + hint = tostring(edition.id + 1), + value = edition.id, + active = edition.id == current_id, + } + end + return items + end, + on_select = function(id) mp.commandv('set', 'edition', id) end, +})) +bind_command('show-in-directory', function() + -- Ignore URLs + if not state.path or is_protocol(state.path) then return end + + if state.platform == 'windows' then + utils.subprocess_detached({args = {'explorer', '/select,', state.path}, cancellable = false}) + elseif state.platform == 'macos' then + utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false}) + elseif state.platform == 'linux' then + local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false}) + + -- Fallback opens the folder with xdg-open instead + if result.status ~= 0 then + utils.subprocess({args = {'xdg-open', serialize_path(state.path).dirname}, cancellable = false}) + end + end end) -mp.add_key_binding(nil, 'menu', function() - if menu:is_open('menu') then - menu:close() - elseif state.context_menu_items then - menu:open(state.context_menu_items, - function(command) mp.command(command) end, {type = 'menu'}) - end +bind_command('stream-quality', function() + if Menu:is_open('stream-quality') then Menu:close() return end + + local ytdl_format = mp.get_property_native('ytdl-format') + local items = {} + + for _, height in ipairs(config.stream_quality_options) do + local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']' + items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format} + end + + Menu:open({type = 'stream-quality', title = 'Stream quality', items = items}, function(format) + mp.set_property('ytdl-format', format) + + -- Reload the video to apply new format + -- This is taken from https://github.com/jgreco/mpv-youtube-quality + -- which is in turn taken from https://github.com/4e6/mpv-reload/ + -- Dunno if playlist_pos shenanigans below are necessary. + local playlist_pos = mp.get_property_number('playlist-pos') + local duration = mp.get_property_native('duration') + local time_pos = mp.get_property('time-pos') + + mp.set_property_number('playlist-pos', playlist_pos) + + -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero + -- duration property. When reloading VOD, to keep the current time position + -- we should provide offset from the start. Stream doesn't have fixed start. + -- Decent choice would be to reload stream from it's current 'live' position. + -- That's the reason we don't pass the offset when reloading streams. + if duration and duration > 0 then + local function seeker() + mp.commandv('seek', time_pos, 'absolute') + mp.unregister_event(seeker) + end + mp.register_event('file-loaded', seeker) + end + end) end) -mp.add_key_binding(nil, 'load-subtitles', function() - if menu:is_open('load-subtitles') then - menu:close() - return - end +bind_command('open-file', function() + if Menu:is_open('open-file') then Menu:close() return end - local path = mp.get_property_native('path') - if path and not is_protocol(path) then - open_file_navigation_menu(serialize_path(path).dirname, function(path) - mp.commandv('sub-add', path) - end, {type = 'load-subtitles', allowed_types = options.subtitle_types}) - end + local directory + local active_file + + if state.path == nil or is_protocol(state.path) then + local serialized = serialize_path(get_default_directory()) + if serialized then + directory = serialized.path + active_file = nil + end + else + local serialized = serialize_path(state.path) + if serialized then + directory = serialized.dirname + active_file = serialized.path + end + end + + if not directory then + msg.error('Couldn\'t serialize path "' .. state.path .. '".') + return + end + + -- Update active file in directory navigation menu + local function handle_file_loaded() + if Menu:is_open('open-file') then + Elements.menu:activate_one_value(normalize_path(mp.get_property_native('path'))) + end + end + + open_file_navigation_menu( + directory, + function(path) mp.commandv('loadfile', path) end, + { + type = 'open-file', + allowed_types = config.types.media, + active_path = active_file, + on_open = function() mp.register_event('file-loaded', handle_file_loaded) end, + on_close = function() mp.unregister_event(handle_file_loaded) end, + } + ) end) -mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener( - 'Subtitles', 'sub', 'sid')) -mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener( - 'Audio', 'audio', 'aid')) -mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener( - 'Video', 'video', 'vid')) -mp.add_key_binding(nil, 'playlist', function() - if menu:is_open('playlist') then - menu:close() - return - end - - function serialize_playlist() - local pos = mp.get_property_number('playlist-pos-1', 0) - local items = {} - local active_item - for index, item in ipairs(mp.get_property_native('playlist')) do - local is_url = item.filename:find('://') - items[index] = { - title = is_url and item.filename or - serialize_path(item.filename).basename, - hint = tostring(index), - value = index - } - - if index == pos then active_item = index end - end - return items, active_item - end - - -- Update active index and playlist content on playlist changes - function handle_playlist_change() - if menu:is_open('playlist') then - local items, active_item = serialize_playlist() - elements.menu:set_items(items, { - active_item = active_item, - selected_item = active_item - }) - end - end - - local items, active_item = serialize_playlist() - - menu:open(items, function(index) - mp.commandv('set', 'playlist-pos-1', tostring(index)) - end, { - type = 'playlist', - title = 'Playlist', - active_item = active_item, - on_open = function() - mp.observe_property('playlist', 'native', handle_playlist_change) - mp.observe_property('playlist-pos-1', 'native', - handle_playlist_change) - end, - on_close = function() - mp.unobserve_property(handle_playlist_change) - end - }) +bind_command('shuffle', function() set_state('shuffle', not state.shuffle) end) +bind_command('items', function() + if state.has_playlist then + mp.command('script-binding uosc/playlist') + else + mp.command('script-binding uosc/open-file') + end end) -mp.add_key_binding(nil, 'chapters', function() - if menu:is_open('chapters') then - menu:close() - return - end - - local items = {} - local chapters = get_normalized_chapters() - - for index, chapter in ipairs(chapters) do - items[#items + 1] = { - title = chapter.title or '', - hint = mp.format_time(chapter.time), - value = chapter.time - } - end - - -- Select first chapter from the end with time lower - -- than current playing position (with 100ms leeway). - function get_selected_chapter_index() - local position = mp.get_property_native('playback-time') - if not position then return nil end - for index = #items, 1, -1 do - if position - 0.1 > items[index].value then return index end - end - end - - -- Update selected chapter in chapter navigation menu - function seek_handler() - if menu:is_open('chapters') then - elements.menu:activate_index(get_selected_chapter_index()) - end - end - - menu:open(items, function(time) - mp.commandv('seek', tostring(time), 'absolute') - end, { - type = 'chapters', - title = 'Chapters', - active_item = get_selected_chapter_index(), - on_open = function() mp.register_event('seek', seek_handler) end, - on_close = function() mp.unregister_event(seek_handler) end - }) +bind_command('next', function() navigate_item(1) end) +bind_command('prev', function() navigate_item(-1) end) +bind_command('next-file', function() navigate_directory(1) end) +bind_command('prev-file', function() navigate_directory(-1) end) +bind_command('first', function() + if state.has_playlist then + mp.commandv('set', 'playlist-pos-1', '1') + else + load_file_index_in_current_directory(1) + end end) -mp.add_key_binding(nil, 'show-in-directory', function() - local path = mp.get_property_native('path') - - -- Ignore URLs - if not path or is_protocol(path) then return end - - path = normalize_path(path) - - if state.os == 'windows' then - utils.subprocess_detached({ - args = {'explorer', '/select,', path}, - cancellable = false - }) - elseif state.os == 'macos' then - utils.subprocess_detached({ - args = {'open', '-R', path}, - cancellable = false - }) - elseif state.os == 'linux' then - local result = utils.subprocess({ - args = {'nautilus', path}, - cancellable = false - }) - - -- Fallback opens the folder with xdg-open instead - if result.status ~= 0 then - utils.subprocess({ - args = {'xdg-open', serialize_path(path).dirname}, - cancellable = false - }) - end - end +bind_command('last', function() + if state.has_playlist then + mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count)) + else + load_file_index_in_current_directory(-1) + end end) -mp.add_key_binding(nil, 'open-file', function() - if menu:is_open('open-file') then - menu:close() - return - end +bind_command('first-file', function() load_file_index_in_current_directory(1) end) +bind_command('last-file', function() load_file_index_in_current_directory(-1) end) +bind_command('delete-file-next', function() + local next_file = nil + local is_local_file = state.path and not is_protocol(state.path) - local path = mp.get_property_native('path') - local directory - local active_file + if is_local_file then + if Menu:is_open('open-file') then Elements.menu:delete_value(state.path) end + end - if path == nil or is_protocol(path) then - local path = serialize_path(mp.command_native({'expand-path', '~/'})) - directory = path.path - active_file = nil - else - local path = serialize_path(path) - directory = path.dirname - active_file = path.path - end + if state.has_playlist then + mp.commandv('playlist-remove', 'current') + else + if is_local_file then + local paths, current_index = get_adjacent_files(state.path, config.types.autoload) + if paths and current_index then + local index, path = decide_navigation_in_list(paths, current_index, 1) + if path then next_file = path end + end + end - -- Update selected file in directory navigation menu - function handle_file_loaded() - if menu:is_open('open-file') then - local path = normalize_path(mp.get_property_native('path')) - elements.menu:activate_value(path) - elements.menu:select_value(path) - end - end + if next_file then mp.commandv('loadfile', next_file) + else mp.commandv('stop') end + end - open_file_navigation_menu(directory, - function(path) mp.commandv('loadfile', path) end, - { - type = 'open-file', - allowed_types = options.media_types, - active_path = active_file, - on_open = function() - mp.register_event('file-loaded', handle_file_loaded) - end, - on_close = function() mp.unregister_event(handle_file_loaded) end - }) + if is_local_file then delete_file(state.path) end end) -mp.add_key_binding(nil, 'next', function() - if mp.get_property_native('playlist-count') > 1 then - mp.command('playlist-next') - else - navigate_directory('forward') - end +bind_command('delete-file-quit', function() + mp.command('stop') + if state.path and not is_protocol(state.path) then delete_file(state.path) end + mp.command('quit') end) -mp.add_key_binding(nil, 'prev', function() - if mp.get_property_native('playlist-count') > 1 then - mp.command('playlist-prev') - else - navigate_directory('backward') - end +bind_command('audio-device', create_self_updating_menu_opener({ + title = 'Audio devices', + type = 'audio-device-list', + list_prop = 'audio-device-list', + active_prop = 'audio-device', + serializer = function(audio_device_list, current_device) + current_device = current_device or 'auto' + local ao = mp.get_property('current-ao') or '' + local items = {} + for _, device in ipairs(audio_device_list) do + if device.name == 'auto' or string.match(device.name, '^' .. ao) then + local hint = string.match(device.name, ao .. '/(.+)') + if not hint then hint = device.name end + items[#items + 1] = { + title = device.description, + hint = hint, + active = device.name == current_device, + value = device.name, + } + end + end + return items + end, + on_select = function(name) mp.commandv('set', 'audio-device', name) end, +})) +bind_command('open-config-directory', function() + local config_path = mp.command_native({'expand-path', '~~/mpv.conf'}) + local config = serialize_path(normalize_path(config_path)) + + if config then + local args + + if state.platform == 'windows' then + args = {'explorer', '/select,', config.path} + elseif state.platform == 'macos' then + args = {'open', '-R', config.path} + elseif state.platform == 'linux' then + args = {'xdg-open', config.dirname} + end + + utils.subprocess_detached({args = args, cancellable = false}) + else + msg.error('Couldn\'t serialize config path "' .. config_path .. '".') + end end) -mp.add_key_binding(nil, 'next-file', - function() navigate_directory('forward') end) -mp.add_key_binding(nil, 'prev-file', - function() navigate_directory('backward') end) -mp.add_key_binding(nil, 'first', function() - if mp.get_property_native('playlist-count') > 1 then - mp.commandv('set', 'playlist-pos-1', '1') - else - load_file_in_current_directory(1) - end + +--[[ MESSAGE HANDLERS ]] + +mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end) +mp.register_script_message('show-submenu-blurred', function(id) + toggle_menu_with_items({submenu = id, mouse_nav = true}) end) -mp.add_key_binding(nil, 'last', function() - local playlist_count = mp.get_property_native('playlist-count') - if playlist_count > 1 then - mp.commandv('set', 'playlist-pos-1', tostring(playlist_count)) - else - load_file_in_current_directory(-1) - end +mp.register_script_message('get-version', function(script) + mp.commandv('script-message-to', script, 'uosc-version', config.version) end) -mp.add_key_binding(nil, 'first-file', - function() load_file_in_current_directory(1) end) -mp.add_key_binding(nil, 'last-file', - function() load_file_in_current_directory(-1) end) -mp.add_key_binding(nil, 'delete-file-next', function() - local path = mp.get_property_native('path') - - if not path or is_protocol(path) then return end - - path = normalize_path(path) - local playlist_count = mp.get_property_native('playlist-count') - - if playlist_count > 1 then - mp.commandv('playlist-remove', 'current') - else - local next_file = - get_adjacent_file(path, 'forward', options.media_types) - - if menu:is_open('open-file') then - elements.menu:delete_value(path) - end - - if next_file then - mp.commandv('loadfile', next_file) - else - mp.commandv('stop') - end - end - - os.remove(path) +mp.register_script_message('open-menu', function(json, submenu_id) + local data = utils.parse_json(json) + if type(data) ~= 'table' or type(data.items) ~= 'table' then + msg.error('open-menu: received json didn\'t produce a table with menu configuration') + else + if data.type and Menu:is_open(data.type) then Menu:close() + else open_command_menu(data, {submenu = submenu_id, on_close = data.on_close}) end + end end) -mp.add_key_binding(nil, 'delete-file-quit', function() - local path = mp.get_property_native('path') - if not path or is_protocol(path) then return end - os.remove(normalize_path(path)) - mp.command('quit') +mp.register_script_message('update-menu', function(json) + local data = utils.parse_json(json) + if type(data) ~= 'table' or type(data.items) ~= 'table' then + msg.error('update-menu: received json didn\'t produce a table with menu configuration') + else + local menu = data.type and Menu:is_open(data.type) + if menu then menu:update(data) + else open_command_menu(data) end + end end) -mp.add_key_binding(nil, 'open-config-directory', function() - local config = serialize_path(mp.command_native( - {'expand-path', '~~/mpv.conf'})) - local args - - if state.os == 'windows' then - args = {'explorer', '/select,', config.path} - elseif state.os == 'macos' then - args = {'open', '-R', config.path} - elseif state.os == 'linux' then - args = {'xdg-open', config.dirname} - end - - utils.subprocess_detached({args = args, cancellable = false}) +mp.register_script_message('thumbfast-info', function(json) + local data = utils.parse_json(json) + if type(data) ~= 'table' or not data.width or not data.height then + thumbnail.disabled = true + msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information') + else + thumbnail = data + request_render() + end end) +mp.register_script_message('set', function(name, value) + external[name] = value + Elements:trigger('external_prop_' .. name, value) +end) +mp.register_script_message('toggle-elements', function(elements) Elements:toggle(split(elements, ' *, *')) end) +mp.register_script_message('set-min-visibility', function(visibility, elements) + local fraction = tonumber(visibility) + local ids = split(elements and elements ~= '' and elements or 'timeline,controls,volume,top_bar', ' *, *') + if fraction then Elements:set_min_visibility(clamp(0, fraction, 1), ids) end +end) +mp.register_script_message('flash-elements', function(elements) Elements:flash(split(elements, ' *, *')) end) +mp.register_script_message('overwrite-binding', function(name, command) key_binding_overwrites[name] = command end) + +--[[ ELEMENTS ]] + +require('uosc_shared/elements/WindowBorder'):new() +require('uosc_shared/elements/BufferingIndicator'):new() +require('uosc_shared/elements/PauseIndicator'):new() +require('uosc_shared/elements/TopBar'):new() +require('uosc_shared/elements/Timeline'):new() +if options.controls and options.controls ~= 'never' then require('uosc_shared/elements/Controls'):new() end +if itable_index_of({'left', 'right'}, options.volume) then require('uosc_shared/elements/Volume'):new() end +require('uosc_shared/elements/Curtain'):new() diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua new file mode 100644 index 0000000..e2aa071 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua @@ -0,0 +1,37 @@ +local Element = require('uosc_shared/elements/Element') + +---@class BufferingIndicator : Element +local BufferingIndicator = class(Element) + +function BufferingIndicator:new() return Class.new(self) --[[@as BufferingIndicator]] end +function BufferingIndicator:init() + Element.init(self, 'buffer_indicator') + self.ignores_menu = true + self.enabled = false +end + +function BufferingIndicator:decide_enabled() + local cache = state.cache_underrun or state.cache_buffering and state.cache_buffering < 100 + local player = state.core_idle and not state.eof_reached + if self.enabled then + if not player or (state.pause and not cache) then self.enabled = false end + elseif player and cache and state.uncached_ranges then self.enabled = true end +end + +function BufferingIndicator:on_prop_pause() self:decide_enabled() end +function BufferingIndicator:on_prop_core_idle() self:decide_enabled() end +function BufferingIndicator:on_prop_eof_reached() self:decide_enabled() end +function BufferingIndicator:on_prop_uncached_ranges() self:decide_enabled() end +function BufferingIndicator:on_prop_cache_buffering() self:decide_enabled() end +function BufferingIndicator:on_prop_cache_underrun() self:decide_enabled() end + +function BufferingIndicator:render() + local ass = assdraw.ass_new() + ass:rect(0, 0, display.width, display.height, {color = bg, opacity = 0.3}) + local size = round(30 + math.min(display.width, display.height) / 10) + local opacity = (Elements.menu and not Elements.menu.is_closing) and 0.3 or 0.8 + ass:spinner(display.width / 2, display.height / 2, size, {color = fg, opacity = opacity}) + return ass +end + +return BufferingIndicator diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Button.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Button.lua new file mode 100644 index 0000000..e57d614 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Button.lua @@ -0,0 +1,90 @@ +local Element = require('uosc_shared/elements/Element') + +---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string} + +---@class Button : Element +local Button = class(Element) + +---@param id string +---@param props ButtonProps +function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end +---@param id string +---@param props ButtonProps +function Button:init(id, props) + self.icon = props.icon + self.active = props.active + self.tooltip = props.tooltip + self.badge = props.badge + self.foreground = props.foreground or fg + self.background = props.background or bg + ---@type fun() + self.on_click = props.on_click + Element.init(self, id, props) +end + +function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end +function Button:handle_cursor_down() + -- We delay the callback to next tick, otherwise we are risking race + -- conditions as we are in the middle of event dispatching. + -- For example, handler might add a menu to the end of the element stack, and that + -- than picks up this click event we are in right now, and instantly closes itself. + mp.add_timeout(0.01, self.on_click) +end + +function Button:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + if self.proximity_raw == 0 then + cursor.on_primary_down = function() self:handle_cursor_down() end + end + + local ass = assdraw.ass_new() + local is_hover = self.proximity_raw == 0 + local is_hover_or_active = is_hover or self.active + local foreground = self.active and self.background or self.foreground + local background = self.active and self.foreground or self.background + + -- Background + if is_hover_or_active then + ass:rect(self.ax, self.ay, self.bx, self.by, { + color = self.active and background or foreground, radius = 2, + opacity = visibility * (self.active and 1 or 0.3), + }) + end + + -- Tooltip on hover + if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end + + -- Badge + local icon_clip + if self.badge then + local badge_font_size = self.font_size * 0.6 + local badge_opts = {size = badge_font_size, color = background, opacity = visibility} + local badge_width = text_width(self.badge, badge_opts) + local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93) + local bx, by = self.bx - 1, self.by - 1 + ass:rect(bx - width, by - height, bx, by, { + color = foreground, radius = 2, opacity = visibility, + border = self.active and 0 or 1, border_color = background, + }) + ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts) + + local clip_border = math.max(self.font_size / 20, 1) + local clip_path = assdraw.ass_new() + clip_path:round_rect_cw( + math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3 + ) + icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')' + end + + -- Icon + local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2) + ass:icon(x, y, self.font_size, self.icon, { + color = foreground, border = self.active and 0 or options.text_border, border_color = background, + opacity = visibility, clip = icon_clip, + }) + + return ass +end + +return Button diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Controls.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Controls.lua new file mode 100644 index 0000000..9a6be72 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Controls.lua @@ -0,0 +1,329 @@ +local Element = require('uosc_shared/elements/Element') +local Button = require('uosc_shared/elements/Button') +local CycleButton = require('uosc_shared/elements/CycleButton') +local Speed = require('uosc_shared/elements/Speed') + +-- `scale` - `options.controls_size` scale factor. +-- `ratio` - Width/height ratio of a static or dynamic element. +-- `ratio_min` Min ratio for 'dynamic' sized element. +---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table} + +---@class Controls : Element +local Controls = class(Element) + +function Controls:new() return Class.new(self) --[[@as Controls]] end +function Controls:init() + Element.init(self, 'controls') + ---@type ControlItem[] All control elements serialized from `options.controls`. + self.controls = {} + ---@type ControlItem[] Only controls that match current dispositions. + self.layout = {} + + -- Serialize control elements + local shorthands = { + menu = 'command:menu:script-binding uosc/menu-blurred?Menu', + subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?Subtitles', + audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?Audio', + ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device', + video = 'command:theaters:script-binding uosc/video#video>1?Video', + playlist = 'command:list_alt:script-binding uosc/playlist?Playlist', + chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?Chapters', + ['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?Editions', + ['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?Stream quality', + ['open-file'] = 'command:file_open:script-binding uosc/open-file?Open file', + ['items'] = 'command:list_alt:script-binding uosc/items?Playlist/Files', + prev = 'command:arrow_back_ios:script-binding uosc/prev?Previous', + next = 'command:arrow_forward_ios:script-binding uosc/next?Next', + first = 'command:first_page:script-binding uosc/first?First', + last = 'command:last_page:script-binding uosc/last?Last', + ['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?Loop playlist', + ['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?Loop file', + shuffle = 'toggle:shuffle:shuffle?Shuffle', + fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen', + } + + -- Parse out disposition/config pairs + local items = {} + local in_disposition = false + local current_item = nil + for c in options.controls:gmatch('.') do + if not current_item then current_item = {disposition = '', config = ''} end + if c == '<' and #current_item.config == 0 then in_disposition = true + elseif c == '>' and #current_item.config == 0 then in_disposition = false + elseif c == ',' and not in_disposition then + items[#items + 1] = current_item + current_item = nil + else + local prop = in_disposition and 'disposition' or 'config' + current_item[prop] = current_item[prop] .. c + end + end + items[#items + 1] = current_item + + -- Create controls + self.controls = {} + for i, item in ipairs(items) do + local config = shorthands[item.config] and shorthands[item.config] or item.config + local config_tooltip = split(config, ' *%? *') + local tooltip = config_tooltip[2] + config = shorthands[config_tooltip[1]] + and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1] + local config_badge = split(config, ' *# *') + config = config_badge[1] + local badge = config_badge[2] + local parts = split(config, ' *: *') + local kind, params = parts[1], itable_slice(parts, 2) + + -- Serialize dispositions + local dispositions = {} + for _, definition in ipairs(split(item.disposition, ' *, *')) do + if #definition > 0 then + local value = definition:sub(1, 1) ~= '!' + local name = not value and definition:sub(2) or definition + local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name + dispositions[prop] = value + end + end + + -- Convert toggles into cycles + if kind == 'toggle' then + kind = 'cycle' + params[#params + 1] = 'no/yes!' + end + + -- Create a control element + local control = {dispositions = dispositions, kind = kind} + + if kind == 'space' then + control.sizing = 'space' + elseif kind == 'gap' then + table_assign(control, {sizing = 'dynamic', scale = 1, ratio = params[1] or 0.3, ratio_min = 0}) + elseif kind == 'command' then + if #params ~= 2 then + mp.error(string.format( + 'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/') + )) + else + local element = Button:new('control_' .. i, { + icon = params[1], + anchor_id = 'controls', + on_click = function() mp.command(params[2]) end, + tooltip = tooltip, + count_prop = 'sub', + }) + table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) + if badge then self:register_badge_updater(badge, element) end + end + elseif kind == 'cycle' then + if #params ~= 3 then + mp.error(string.format( + 'cycle button needs 3 parameters, %d received: %s', + #params, table.concat(params, '/') + )) + else + local state_configs = split(params[3], ' */ *') + local states = {} + + for _, state_config in ipairs(state_configs) do + local active = false + if state_config:sub(-1) == '!' then + active = true + state_config = state_config:sub(1, -2) + end + local state_params = split(state_config, ' *= *') + local value, icon = state_params[1], state_params[2] or params[1] + states[#states + 1] = {value = value, icon = icon, active = active} + end + + local element = CycleButton:new('control_' .. i, { + prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip, + }) + table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) + if badge then self:register_badge_updater(badge, element) end + end + elseif kind == 'speed' then + if not Elements.speed then + local element = Speed:new({anchor_id = 'controls'}) + table_assign(control, { + element = element, sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2, + }) + else + msg.error('there can only be 1 speed slider') + end + else + msg.error('unknown element kind "' .. kind .. '"') + break + end + + self.controls[#self.controls + 1] = control + end + + self:reflow() +end + +function Controls:reflow() + -- Populate the layout only with items that match current disposition + self.layout = {} + for _, control in ipairs(self.controls) do + local matches = true + for prop, value in pairs(control.dispositions) do + if state[prop] ~= value then + matches = false + break + end + end + if control.element then control.element.enabled = matches end + if matches then self.layout[#self.layout + 1] = control end + end + + self:update_dimensions() + Elements:trigger('controls_reflow') +end + +---@param badge string +---@param element Element An element that supports `badge` property. +function Controls:register_badge_updater(badge, element) + local prop_and_limit = split(badge, ' *> *') + local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1) + local observable_name, serializer, is_external_prop = prop, nil, false + + if itable_index_of({'sub', 'audio', 'video'}, prop) then + observable_name = 'track-list' + serializer = function(value) + local count = 0 + for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end + return count + end + else + local parts = split(prop, '@') + -- Support both new `prop@owner` and old `@prop` syntaxes + if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end + serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end + end + + local function handler(_, value) + local new_value = serializer(value) --[[@as nil|string|integer]] + local value_number = tonumber(new_value) + if value_number then new_value = value_number > limit and value_number or nil end + element.badge = new_value + request_render() + end + + if is_external_prop then element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end + else mp.observe_property(observable_name, 'native', handler) end +end + +function Controls:get_visibility() + return (Elements.speed and Elements.speed.dragging) and 1 or Elements.timeline:get_is_hovered() + and -1 or Element.get_visibility(self) +end + +function Controls:update_dimensions() + local window_border = Elements.window_border.size + local size = state.fullormaxed and options.controls_size_fullscreen or options.controls_size + local spacing = options.controls_spacing + local margin = options.controls_margin + + -- Disable when not enough space + local available_space = display.height - Elements.window_border.size * 2 + if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end + if Elements.timeline.enabled then available_space = available_space - Elements.timeline.size_max end + self.enabled = available_space > size + 10 + + -- Reset hide/enabled flags + for c, control in ipairs(self.layout) do + control.hide = false + if control.element then control.element.enabled = self.enabled end + end + + if not self.enabled then return end + + -- Container + self.bx = display.width - window_border - margin + self.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin + self.ax, self.ay = window_border + margin, self.by - size + + -- Controls + local available_width = self.bx - self.ax + local statics_width = (#self.layout - 1) * spacing + local min_content_width = statics_width + local max_dynamics_width, dynamic_units, spaces = 0, 0, 0 + + -- Calculate statics_width, min_content_width, and count spaces + for c, control in ipairs(self.layout) do + if control.sizing == 'space' then + spaces = spaces + 1 + elseif control.sizing == 'static' then + local width = size * control.scale * control.ratio + statics_width = statics_width + width + min_content_width = min_content_width + width + elseif control.sizing == 'dynamic' then + min_content_width = min_content_width + size * control.scale * control.ratio_min + max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio + dynamic_units = dynamic_units + control.scale * control.ratio + end + end + + -- Hide & disable elements in the middle until we fit into available width + if min_content_width > available_width then + local i = math.ceil(#self.layout / 2 + 0.1) + for a = 0, #self.layout - 1, 1 do + i = i + (a * (a % 2 == 0 and 1 or -1)) + local control = self.layout[i] + + if control.kind ~= 'gap' and control.kind ~= 'space' then + control.hide = true + if control.element then control.element.enabled = false end + if control.sizing == 'static' then + local width = size * control.scale * control.ratio + min_content_width = min_content_width - width - spacing + statics_width = statics_width - width - spacing + elseif control.sizing == 'dynamic' then + min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing + max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio + dynamic_units = dynamic_units - control.scale * control.ratio + end + + if min_content_width < available_width then break end + end + end + end + + -- Lay out the elements + local current_x = self.ax + local width_for_dynamics = available_width - statics_width + local space_width = (width_for_dynamics - max_dynamics_width) / spaces + + for c, control in ipairs(self.layout) do + if not control.hide then + local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio + local width, height = 0, 0 + + if sizing == 'space' then + if space_width > 0 then width = space_width end + elseif sizing == 'static' then + height = size * scale + width = height * ratio + elseif sizing == 'dynamic' then + height = size * scale + width = max_dynamics_width < width_for_dynamics + and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units) + end + + local bx = current_x + width + if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end + current_x = bx + spacing + end + end + + Elements:update_proximities() + request_render() +end + +function Controls:on_dispositions() self:reflow() end +function Controls:on_display() self:update_dimensions() end +function Controls:on_prop_border() self:update_dimensions() end +function Controls:on_prop_fullormaxed() self:update_dimensions() end +function Controls:on_timeline_enabled() self:update_dimensions() end + +return Controls diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Curtain.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Curtain.lua new file mode 100644 index 0000000..99b9f14 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Curtain.lua @@ -0,0 +1,35 @@ +local Element = require('uosc_shared/elements/Element') + +---@class Curtain : Element +local Curtain = class(Element) + +function Curtain:new() return Class.new(self) --[[@as Curtain]] end +function Curtain:init() + Element.init(self, 'curtain', {ignores_menu = true}) + self.opacity = 0 + ---@type string[] + self.dependents = {} +end + +---@param id string +function Curtain:register(id) + self.dependents[#self.dependents + 1] = id + if #self.dependents == 1 then self:tween_property('opacity', self.opacity, 1) end +end + +---@param id string +function Curtain:unregister(id) + self.dependents = itable_filter(self.dependents, function(item) return item ~= id end) + if #self.dependents == 0 then self:tween_property('opacity', self.opacity, 0) end +end + +function Curtain:render() + if self.opacity == 0 or options.curtain_opacity == 0 then return end + local ass = assdraw.ass_new() + ass:rect(0, 0, display.width, display.height, { + color = '000000', opacity = options.curtain_opacity * self.opacity, + }) + return ass +end + +return Curtain diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua new file mode 100644 index 0000000..7f1c02f --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua @@ -0,0 +1,64 @@ +local Button = require('uosc_shared/elements/Button') + +---@alias CycleState {value: any; icon: string; active?: boolean} +---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string} + +---@class CycleButton : Button +local CycleButton = class(Button) + +---@param id string +---@param props CycleButtonProps +function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end +---@param id string +---@param props CycleButtonProps +function CycleButton:init(id, props) + local is_state_prop = itable_index_of({'shuffle'}, props.prop) + self.prop = props.prop + self.states = props.states + + Button.init(self, id, props) + + self.icon = self.states[1].icon + self.active = self.states[1].active + self.current_state_index = 1 + self.on_click = function() + local new_state = self.states[self.current_state_index + 1] or self.states[1] + local new_value = new_state.value + if self.owner then + mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value) + elseif is_state_prop then + if itable_index_of({'yes', 'no'}, new_value) then new_value = new_value == 'yes' end + set_state(self.prop, new_value) + else + mp.set_property(self.prop, new_value) + end + end + + self.handle_change = function(name, value) + if is_state_prop and type(value) == 'boolean' then value = value and 'yes' or 'no' end + local index = itable_find(self.states, function(state) return state.value == value end) + self.current_state_index = index or 1 + self.icon = self.states[self.current_state_index].icon + self.active = self.states[self.current_state_index].active + request_render() + end + + local prop_parts = split(self.prop, '@') + if #prop_parts == 2 then -- External prop with a script owner + self.prop, self.owner = prop_parts[1], prop_parts[2] + self['on_external_prop_' .. self.prop] = function(_, value) self.handle_change(self.prop, value) end + self.handle_change(self.prop, external[self.prop]) + elseif is_state_prop then -- uosc's state props + self['on_prop_' .. self.prop] = function(self, value) self.handle_change(self.prop, value) end + self.handle_change(self.prop, state[self.prop]) + else + mp.observe_property(self.prop, 'string', self.handle_change) + end +end + +function CycleButton:destroy() + Button.destroy(self) + mp.unobserve_property(self.handle_change) +end + +return CycleButton diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Element.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Element.lua new file mode 100644 index 0000000..1bcbe08 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Element.lua @@ -0,0 +1,154 @@ +---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;} + +-- Base class all elements inherit from. +---@class Element : Class +local Element = class() + +---@param id string +---@param props? ElementProps +function Element:init(id, props) + self.id = id + -- `false` means element won't be rendered, or receive events + self.enabled = true + -- Element coordinates + self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0 + -- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range. + self.proximity = 0 + -- Raw proximity in pixels. + self.proximity_raw = INFINITY + ---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility. + self.min_visibility = 0 + ---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations + self.forced_visibility = nil + ---@type boolean Render this element even when menu is open. + self.ignores_menu = false + ---@type nil|string ID of an element from which this one should inherit visibility. + self.anchor_id = nil + + if props then table_assign(self, props) end + + -- Flash timer + self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function() + local function getTo() return self.proximity end + local function onTweenEnd() self.forced_visibility = nil end + if self.enabled then self:tween_property('forced_visibility', 1, getTo, onTweenEnd) + else onTweenEnd() end + end) + self._flash_out_timer:kill() + + Elements:add(self) +end + +function Element:destroy() + self.destroyed = true + Elements:remove(self) +end + +function Element:reset_proximity() self.proximity, self.proximity_raw = 0, INFINITY end + +---@param ax number +---@param ay number +---@param bx number +---@param by number +function Element:set_coordinates(ax, ay, bx, by) + self.ax, self.ay, self.bx, self.by = ax, ay, bx, by + Elements:update_proximities() + self:maybe('on_coordinates') +end + +function Element:update_proximity() + if cursor.hidden then + self:reset_proximity() + else + local range = options.proximity_out - options.proximity_in + self.proximity_raw = get_point_to_rectangle_proximity(cursor, self) + self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range) + end +end + +function Element:is_persistent() + local persist = config[self.id .. '_persistency'] + return persist and ( + (persist.audio and state.is_audio) + or (persist.paused and state.pause and (not Elements.timeline.pressed or Elements.timeline.pressed.pause)) + or (persist.video and state.is_video) + or (persist.image and state.is_image) + or (persist.idle and state.is_idle) + ) +end + +-- Decide elements visibility based on proximity and various other factors +function Element:get_visibility() + -- Hide when menu is open, unless this is a menu + ---@diagnostic disable-next-line: undefined-global + if not self.ignores_menu and Menu and Menu:is_open() then return 0 end + + -- Persistency + if self:is_persistent() then return 1 end + + -- Forced visibility + if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end + + -- Anchor inheritance + -- If anchor returns -1, it means all attached elements should force hide. + local anchor = self.anchor_id and Elements[self.anchor_id] + local anchor_visibility = anchor and anchor:get_visibility() or 0 + + return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility) +end + +-- Call method if it exists +function Element:maybe(name, ...) + if self[name] then return self[name](self, ...) end +end + +-- Attach a tweening animation to this element +---@param from number +---@param to number|fun():number +---@param setter fun(value: number) +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function Element:tween(from, to, setter, factor_or_callback, callback) + self:tween_stop() + self._kill_tween = self.enabled and tween( + from, to, setter, factor_or_callback, + function() + self._kill_tween = nil + if callback then callback() end + end + ) +end + +function Element:is_tweening() return self and self._kill_tween end +function Element:tween_stop() self:maybe('_kill_tween') end + +-- Animate an element property between 2 values. +---@param prop string +---@param from number +---@param to number|fun():number +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function Element:tween_property(prop, from, to, factor_or_callback, callback) + self:tween(from, to, function(value) self[prop] = value end, factor_or_callback, callback) +end + +---@param name string +function Element:trigger(name, ...) + local result = self:maybe('on_' .. name, ...) + request_render() + return result +end + +-- Briefly flashes the element for `options.flash_duration` milliseconds. +-- Useful to visualize changes of volume and timeline when changed via hotkeys. +function Element:flash() + if self.enabled and options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then + self:tween_stop() + self.forced_visibility = 1 + request_render() + self._flash_out_timer:kill() + self._flash_out_timer:resume() + end +end + +return Element diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Elements.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Elements.lua new file mode 100644 index 0000000..489819a --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Elements.lua @@ -0,0 +1,125 @@ +local Elements = {itable = {}} + +---@param element Element +function Elements:add(element) + if not element.id then + msg.error('attempt to add element without "id" property') + return + end + + if self:has(element.id) then Elements:remove(element.id) end + + self.itable[#self.itable + 1] = element + self[element.id] = element + + request_render() +end + +function Elements:remove(idOrElement) + if not idOrElement then return end + local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement + local element = Elements[id] + if element then + if not element.destroyed then element:destroy() end + element.enabled = false + self.itable = itable_remove(self.itable, self[id]) + self[id] = nil + request_render() + end +end + +function Elements:update_proximities() + local menu_only = Elements.menu ~= nil + local mouse_leave_elements = {} + local mouse_enter_elements = {} + + -- Calculates proximities and opacities for defined elements + for _, element in self:ipairs() do + if element.enabled then + local previous_proximity_raw = element.proximity_raw + + -- If menu is open, all other elements have to be disabled + if menu_only then + if element.ignores_menu then element:update_proximity() + else element:reset_proximity() end + else + element:update_proximity() + end + + if element.proximity_raw == 0 then + -- Mouse entered element area + if previous_proximity_raw ~= 0 then + mouse_enter_elements[#mouse_enter_elements + 1] = element + end + else + -- Mouse left element area + if previous_proximity_raw == 0 then + mouse_leave_elements[#mouse_leave_elements + 1] = element + end + end + end + end + + -- Trigger `mouse_leave` and `mouse_enter` events + for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end + for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end +end + +-- Toggles passed elements' min visibilities between 0 and 1. +---@param ids string[] IDs of elements to peek. +function Elements:toggle(ids) + local has_invisible = itable_find(ids, function(id) return Elements[id] and Elements[id]:get_visibility() ~= 1 end) + self:set_min_visibility(has_invisible and 1 or 0, ids) + -- Reset proximities when toggling off. Has to happen after `set_min_visibility`, + -- as that is using proximity as a tween starting point. + if not has_invisible then + for _, id in ipairs(ids) do + if Elements[id] then Elements[id]:reset_proximity() end + end + end +end + +-- Set (animate) elements' min visibilities to passed value. +---@param visibility number 0-1 floating point. +---@param ids string[] IDs of elements to peek. +function Elements:set_min_visibility(visibility, ids) + for _, id in ipairs(ids) do + local element = Elements[id] + if element then + local from = math.max(0, element:get_visibility()) + element:tween_property('min_visibility', from, visibility) + end + end +end + +-- Flash passed elements. +---@param ids string[] IDs of elements to peek. +function Elements:flash(ids) + local elements = itable_filter(self.itable, function(element) return itable_index_of(ids, element.id) ~= nil end) + for _, element in ipairs(elements) do element:flash() end +end + +---@param name string Event name. +function Elements:trigger(name, ...) + for _, element in self:ipairs() do element:trigger(name, ...) end +end + +-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity. +-- Disabled elements don't receive these events. +---@param name string Event name. +function Elements:proximity_trigger(name, ...) + for i = #self.itable, 1, -1 do + local element = self.itable[i] + if element.enabled then + if element.proximity_raw == 0 then + if element:trigger(name, ...) == 'stop_propagation' then break end + end + if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end + end + end +end + +function Elements:has(id) return self[id] ~= nil end +function Elements:ipairs() return ipairs(self.itable) end + +return Elements diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Menu.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Menu.lua new file mode 100644 index 0000000..1830647 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Menu.lua @@ -0,0 +1,854 @@ +local Element = require('uosc_shared/elements/Element') + +-- Menu data structure accepted by `Menu:open(menu)`. +---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected_index?: integer;} +---@alias MenuDataItem MenuDataValue|MenuData +---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; keep_open?: boolean; separator?: boolean;} +---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} + +-- Internal data structure created from `Menu`. +---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling} +---@alias MenuStackItem MenuStackValue|MenuStack +---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_width: number; hint_width: number} +---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean} + +---@alias Modifiers {shift?: boolean, ctrl?: boolean, alt?: boolean} +---@alias MenuCallbackMeta {modifiers: Modifiers} +---@alias MenuCallback fun(value: any, meta: MenuCallbackMeta) + +---@class Menu : Element +local Menu = class(Element) + +---@param data MenuData +---@param callback MenuCallback +---@param opts? MenuOptions +function Menu:open(data, callback, opts) + local open_menu = self:is_open() + if open_menu then + open_menu.is_being_replaced = true + open_menu:close(true) + end + return Menu:new(data, callback, opts) +end + +---@param menu_type? string +---@return Menu|nil +function Menu:is_open(menu_type) + return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil +end + +---@param immediate? boolean Close immediately without fadeout animation. +---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed. +---@overload fun(callback: fun()) +function Menu:close(immediate, callback) + if type(immediate) ~= 'boolean' then callback = immediate end + + local menu = self == Menu and Elements.menu or self + + if menu and not menu.destroyed then + if menu.is_closing then + menu:tween_stop() + return + end + + local function close() + Elements:remove('menu') + menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {} + menu:disable_key_bindings() + Elements:update_proximities() + cursor.queue_autohide() + if callback then callback() end + request_render() + end + + menu.is_closing = true + + if immediate then close() + else menu:fadeout(close) end + end +end + +---@param data MenuData +---@param callback MenuCallback +---@param opts? MenuOptions +---@return Menu +function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end +---@param data MenuData +---@param callback MenuCallback +---@param opts? MenuOptions +function Menu:init(data, callback, opts) + Element.init(self, 'menu', {ignores_menu = true}) + + -----@type fun() + self.callback = callback + self.opts = opts or {} + self.offset_x = 0 -- Used for submenu transition animation. + self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items + ---@type Modifiers|nil + self.modifiers = nil + self.item_height = nil + self.item_spacing = 1 + self.item_padding = nil + self.font_size = nil + self.font_size_hint = nil + self.scroll_step = nil -- Item height + item spacing. + self.scroll_height = nil -- Items + spacings - container height. + self.opacity = 0 -- Used to fade in/out. + self.type = data.type + ---@type MenuStack Root MenuStack. + self.root = nil + ---@type MenuStack Current MenuStack. + self.current = nil + ---@type MenuStack[] All menus in a flat array. + self.all = nil + ---@type table Map of submenus by their ids, such as `'Tools > Aspect ratio'`. + self.by_id = {} + self.key_bindings = {} + self.is_being_replaced = false + self.is_closing, self.is_closed = false, false + ---@type {y: integer, time: number}[] + self.drag_data = nil + self.is_dragging = false + + self:update(data) + + if self.mouse_nav then + if self.current then self.current.selected_index = nil end + else + for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu) end + end + + self:tween_property('opacity', 0, 1) + self:enable_key_bindings() + Elements.curtain:register('menu') + if self.opts.on_open then self.opts.on_open() end +end + +function Menu:destroy() + Element.destroy(self) + self:disable_key_bindings() + self.is_closed = true + if not self.is_being_replaced then Elements.curtain:unregister('menu') end + if self.opts.on_close then self.opts.on_close() end +end + +---@param data MenuData +function Menu:update(data) + self.type = data.type + + local new_root = {is_root = true, submenu_path = {}} + local new_all = {} + local new_by_id = {} + local menus_to_serialize = {{new_root, data}} + local old_current_id = self.current and self.current.id + + table_assign(new_root, data, {'type', 'title', 'hint', 'keep_open'}) + + local i = 0 + while i < #menus_to_serialize do + i = i + 1 + local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2] + local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id + if not menu.is_root then + menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i) + end + menu.icon = 'chevron_right' + + -- Update items + local first_active_index = nil + menu.items = {} + + for i, item_data in ipairs(menu_data.items or {}) do + if item_data.active and not first_active_index then first_active_index = i end + + local item = {} + table_assign(item, item_data, { + 'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator', + }) + if item.keep_open == nil then item.keep_open = menu.keep_open end + + -- Submenu + if item_data.items then + item.parent_menu = menu + item.submenu_path = itable_join(menu.submenu_path, {i}) + menus_to_serialize[#menus_to_serialize + 1] = {item, item_data} + end + + menu.items[i] = item + end + + if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end + + -- Retain old state + local old_menu = self.by_id[menu.is_root and '__root__' or menu.id] + if old_menu then table_assign(menu, old_menu, {'selected_index', 'scroll_y', 'fling'}) end + + new_all[#new_all + 1] = menu + new_by_id[menu.is_root and '__root__' or menu.id] = menu + end + + self.root, self.all, self.by_id = new_root, new_all, new_by_id + self.current = self.by_id[old_current_id] or self.root + + self:update_content_dimensions() + self:reset_navigation() +end + +---@param items MenuDataItem[] +function Menu:update_items(items) + local data = table_shallow_copy(self.root) + data.items = items + self:update(data) +end + +function Menu:update_content_dimensions() + self.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height + self.font_size = round(self.item_height * 0.48 * options.font_scale) + self.font_size_hint = self.font_size - 1 + self.item_padding = round((self.item_height - self.font_size) * 0.6) + self.scroll_step = self.item_height + self.item_spacing + + local title_opts = {size = self.font_size, italic = false, bold = false} + local hint_opts = {size = self.font_size_hint} + + for _, menu in ipairs(self.all) do + title_opts.bold, title_opts.italic = true, false + local max_width = text_width(menu.title, title_opts) + 2 * self.item_padding + + -- Estimate width of a widest item + for _, item in ipairs(menu.items) do + local icon_width = item.icon and self.font_size or 0 + item.title_width = text_width(item.title, title_opts) + item.hint_width = text_width(item.hint, hint_opts) + local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0) + + (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0) + local estimated_width = item.title_width + item.hint_width + icon_width + + (self.item_padding * spacings_in_item) + if estimated_width > max_width then max_width = estimated_width end + end + + menu.max_width = max_width + end + + self:update_dimensions() +end + +function Menu:update_dimensions() + -- Coordinates and sizes are of the scrollable area to make + -- consuming values in rendering and collisions easier. Title is rendered + -- above it, so we need to account for that in max_height and ay position. + local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width + + for _, menu in ipairs(self.all) do + menu.width = round(clamp(min_width, menu.max_width, display.width * 0.9)) + local title_height = (menu.is_root and menu.title) and self.scroll_step or 0 + local max_height = round((display.height - title_height) * 0.9) + local content_height = self.scroll_step * #menu.items + menu.height = math.min(content_height - self.item_spacing, max_height) + menu.top = round(math.max((display.height - menu.height) / 2, title_height * 1.5)) + menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0) + menu.scroll_y = menu.scroll_y or 0 + self:scroll_to(menu.scroll_y, menu) -- clamps scroll_y to scroll limits + end + + self:update_coordinates() +end + +-- Updates element coordinates to match currently open (sub)menu. +function Menu:update_coordinates() + local ax = round((display.width - self.current.width) / 2) + self.offset_x + self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height) +end + +function Menu:reset_navigation() + local menu = self.current + + -- Reset indexes and scroll + self:scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits + if self.mouse_nav then + self:select_item_below_cursor() + else + self:select_index((menu.items and #menu.items > 0) and clamp(1, menu.selected_index or 1, #menu.items) or nil) + end + + -- Walk up the parent menu chain and activate items that lead to current menu + local parent = menu.parent_menu + while parent do + parent.selected_index = itable_index_of(parent.items, menu) + menu, parent = parent, parent.parent_menu + end + + request_render() +end + +function Menu:set_offset_x(offset) + local delta = offset - self.offset_x + self.offset_x = offset + self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by) +end + +function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end + +function Menu:get_item_index_below_cursor() + local menu = self.current + if #menu.items < 1 or self.proximity_raw > 0 then return nil end + return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items)) +end + +function Menu:get_first_active_index(menu) + menu = menu or self.current + for index, item in ipairs(self.current.items) do + if item.active then return index end + end +end + +---@param pos? number +---@param menu? MenuStack +function Menu:set_scroll_to(pos, menu) + menu = menu or self.current + menu.scroll_y = clamp(0, pos or 0, menu.scroll_height) + request_render() +end + +---@param delta? number +---@param menu? MenuStack +function Menu:set_scroll_by(delta, menu) + menu = menu or self.current + self:set_scroll_to(menu.scroll_y + delta, menu) +end + +---@param pos? number +---@param menu? MenuStack +---@param fling_options? table +function Menu:scroll_to(pos, menu, fling_options) + menu = menu or self.current + menu.fling = { + y = menu.scroll_y, distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y), + time = mp.get_time(), duration = 0.1, easing = ease_out_sext, + } + if fling_options then table_assign(menu.fling, fling_options) end + request_render() +end + +---@param delta? number +---@param menu? MenuStack +---@param fling_options? Fling +function Menu:scroll_by(delta, menu, fling_options) + menu = menu or self.current + self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu, fling_options) +end + +---@param index? integer +---@param menu? MenuStack +---@param immediate? boolean +function Menu:scroll_to_index(index, menu, immediate) + menu = menu or self.current + if (index and index >= 1 and index <= #menu.items) then + local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2)) + if immediate then self:set_scroll_to(position, menu) + else self:scroll_to(position, menu) end + end +end + +---@param index? integer +---@param menu? MenuStack +function Menu:select_index(index, menu) + menu = menu or self.current + menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil + request_render() +end + +---@param value? any +---@param menu? MenuStack +function Menu:select_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(item) return item.value == value end) + self:select_index(index) +end + +---@param menu? MenuStack +function Menu:deactivate_items(menu) + menu = menu or self.current + for _, item in ipairs(menu.items) do item.active = false end + request_render() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:activate_index(index, menu) + menu = menu or self.current + if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end + request_render() +end + +---@param index? integer +---@param menu? MenuStack +function Menu:activate_one_index(index, menu) + self:deactivate_items(menu) + self:activate_index(index, menu) +end + +---@param value? any +---@param menu? MenuStack +function Menu:activate_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(item) return item.value == value end) + self:activate_index(index, menu) +end + +---@param value? any +---@param menu? MenuStack +function Menu:activate_one_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(item) return item.value == value end) + self:activate_one_index(index, menu) +end + +---@param menu MenuStack One of menus in `self.all`. +function Menu:activate_menu(menu) + if itable_index_of(self.all, menu) then + self.current = menu + self:update_coordinates() + self:reset_navigation() + request_render() + else + msg.error('Attempt to open a menu not in `self.all` list.') + end +end + +---@param id string +function Menu:activate_submenu(id) + local submenu = self.by_id[id] + if submenu then self:activate_menu(submenu) + else msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id)) end +end + +---@param index? integer +---@param menu? MenuStack +function Menu:delete_index(index, menu) + menu = menu or self.current + if (index and index >= 1 and index <= #menu.items) then + table.remove(menu.items, index) + self:update_content_dimensions() + self:scroll_to_index(menu.selected_index, menu) + end +end + +---@param value? any +---@param menu? MenuStack +function Menu:delete_value(value, menu) + menu = menu or self.current + local index = itable_find(menu.items, function(item) return item.value == value end) + self:delete_index(index) +end + +---@param menu? MenuStack +function Menu:prev(menu) + menu = menu or self.current + menu.selected_index = math.max(menu.selected_index and menu.selected_index - 1 or #menu.items, 1) + self:scroll_to_index(menu.selected_index, menu, true) +end + +---@param menu? MenuStack +function Menu:next(menu) + menu = menu or self.current + menu.selected_index = math.min(menu.selected_index and menu.selected_index + 1 or 1, #menu.items) + self:scroll_to_index(menu.selected_index, menu, true) +end + +function Menu:back() + if self.opts.on_back then + self.opts.on_back() + if self.is_closed then return end + end + + local menu = self.current + local parent = menu.parent_menu + + if parent then + menu.selected_index = nil + self:activate_menu(parent) + self:tween(self.offset_x - menu.width / 2, 0, function(offset) self:set_offset_x(offset) end) + self.opacity = 1 -- in case tween above canceled fade in animation + else + self:close() + end +end + +---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean} +function Menu:open_selected_item(opts) + opts = opts or {} + local menu = self.current + if menu.selected_index then + local item = menu.items[menu.selected_index] + -- Is submenu + if item.items then + if opts.preselect_submenu_item then + item.selected_index = #item.items > 0 and 1 or nil + end + self:activate_menu(item) + self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end) + self.opacity = 1 -- in case tween above canceled fade in animation + else + self.callback(item.value, {modifiers = self.modifiers or {}}) + if not item.keep_open and not opts.keep_open then self:close() end + end + end +end + +function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end +function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end +function Menu:select_item_below_cursor() self.current.selected_index = self:get_item_index_below_cursor() end + +---@param index integer +function Menu:move_selected_item_to(index) + local from, callback = self.current.selected_index, self.opts.on_move_item + if callback and from and from ~= index and index >= 1 and index <= #self.current.items then + callback(from, index, self.current.submenu_path) + self.current.selected_index = index + request_render() + end +end + +function Menu:move_selected_item_up() + if self.current.selected_index then self:move_selected_item_to(self.current.selected_index - 1) end +end + +function Menu:move_selected_item_down() + if self.current.selected_index then self:move_selected_item_to(self.current.selected_index + 1) end +end + +function Menu:delete_selected_item() + local index, callback = self.current.selected_index, self.opts.on_delete_item + if callback and index then callback(index, self.current.submenu_path) end +end + +function Menu:on_display() self:update_dimensions() end +function Menu:on_prop_fullormaxed() self:update_content_dimensions() end + +function Menu:handle_cursor_down() + if self.proximity_raw == 0 then + self.drag_data = {{y = cursor.y, time = mp.get_time()}} + self.current.fling = nil + else + if cursor.x < self.ax and self.current.parent_menu then self:back() + else self:close() end + end +end + +function Menu:fling_distance() + local first, last = self.drag_data[1], self.drag_data[#self.drag_data] + if mp.get_time() - last.time > 0.05 then return 0 end + for i = #self.drag_data - 1, 1, -1 do + local drag = self.drag_data[i] + if last.time - drag.time > 0.03 then return ((drag.y - last.y) / ((last.time - drag.time) / 0.03)) * 10 end + end + return #self.drag_data < 2 and 0 or ((first.y - last.y) / ((first.time - last.time) / 0.03)) * 10 +end + +function Menu:handle_cursor_up() + if self.proximity_raw == 0 and self.drag_data and not self.is_dragging then + self:select_item_below_cursor() + self:open_selected_item({preselect_submenu_item = false, keep_open = self.modifiers and self.modifiers.shift}) + end + if self.is_dragging then + local distance = self:fling_distance() + if math.abs(distance) > 50 then + self.current.fling = { + y = self.current.scroll_y, distance = distance, time = self.drag_data[#self.drag_data].time, + easing = ease_out_quart, duration = 0.5, update_cursor = true, + } + end + end + self.is_dragging = false + self.drag_data = nil +end + + +function Menu:on_global_mouse_move() + self.mouse_nav = true + if self.drag_data then + self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_data[1].y) >= 10 + local distance = self.drag_data[#self.drag_data].y - cursor.y + if distance ~= 0 then self:set_scroll_by(distance) end + self.drag_data[#self.drag_data + 1] = {y = cursor.y, time = mp.get_time()} + end + if self.proximity_raw == 0 or self.is_dragging then self:select_item_below_cursor() + else self.current.selected_index = nil end + request_render() +end + +function Menu:handle_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end +function Menu:handle_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end + +function Menu:on_pgup() + local menu = self.current + local items_per_page = round((menu.height / self.scroll_step) * 0.4) + local paged_index = (menu.selected_index and menu.selected_index or #menu.items) - items_per_page + menu.selected_index = clamp(1, paged_index, #menu.items) + if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end +end + +function Menu:on_pgdwn() + local menu = self.current + local items_per_page = round((menu.height / self.scroll_step) * 0.4) + local paged_index = (menu.selected_index and menu.selected_index or 1) + items_per_page + menu.selected_index = clamp(1, paged_index, #menu.items) + if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end +end + +function Menu:on_home() + self.current.selected_index = math.min(1, #self.current.items) + if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end +end + +function Menu:on_end() + self.current.selected_index = #self.current.items + if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end +end + +function Menu:add_key_binding(key, name, fn, flags) + self.key_bindings[#self.key_bindings + 1] = name + mp.add_forced_key_binding(key, name, fn, flags) +end + +function Menu:enable_key_bindings() + -- The `mp.set_key_bindings()` method would be easier here, but that + -- doesn't support 'repeatable' flag, so we are stuck with this monster. + self:add_key_binding('up', 'menu-prev1', self:create_key_action('prev'), 'repeatable') + self:add_key_binding('down', 'menu-next1', self:create_key_action('next'), 'repeatable') + self:add_key_binding('ctrl+up', 'menu-move-up', self:create_key_action('move_selected_item_up'), 'repeatable') + self:add_key_binding('ctrl+down', 'menu-move-down', self:create_key_action('move_selected_item_down'), 'repeatable') + self:add_key_binding('left', 'menu-back1', self:create_key_action('back')) + self:add_key_binding('right', 'menu-select1', self:create_key_action('open_selected_item_preselect')) + self:add_key_binding('shift+right', 'menu-select-soft1', + self:create_key_action('open_selected_item_soft', {shift = true})) + self:add_key_binding('shift+mbtn_left', 'menu-select3', self:create_modified_mbtn_left_handler({shift = true})) + self:add_key_binding('ctrl+mbtn_left', 'menu-select4', self:create_modified_mbtn_left_handler({ctrl = true})) + self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_key_action('back')) + self:add_key_binding('bs', 'menu-back-alt4', self:create_key_action('back')) + self:add_key_binding('enter', 'menu-select-alt3', self:create_key_action('open_selected_item_preselect')) + self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_key_action('open_selected_item_preselect')) + self:add_key_binding('ctrl+enter', 'menu-select-ctrl1', + self:create_key_action('open_selected_item_preselect', {ctrl = true})) + self:add_key_binding('ctrl+kp_enter', 'menu-select-ctrl2', + self:create_key_action('open_selected_item_preselect', {ctrl = true})) + self:add_key_binding('shift+enter', 'menu-select-alt5', + self:create_key_action('open_selected_item_soft', {shift = true})) + self:add_key_binding('shift+kp_enter', 'menu-select-alt6', + self:create_key_action('open_selected_item_soft', {shift = true})) + self:add_key_binding('esc', 'menu-close', self:create_key_action('close')) + self:add_key_binding('pgup', 'menu-page-up', self:create_key_action('on_pgup'), 'repeatable') + self:add_key_binding('pgdwn', 'menu-page-down', self:create_key_action('on_pgdwn'), 'repeatable') + self:add_key_binding('home', 'menu-home', self:create_key_action('on_home')) + self:add_key_binding('end', 'menu-end', self:create_key_action('on_end')) + self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item')) +end + +function Menu:disable_key_bindings() + for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end + self.key_bindings = {} +end + +---@param modifiers Modifiers +function Menu:create_modified_mbtn_left_handler(modifiers) + return function() + self.mouse_nav = true + self.modifiers = modifiers + self:handle_cursor_down() + self:handle_cursor_up() + self.modifiers = nil + end +end + +---@param name string +---@param modifiers? Modifiers +function Menu:create_key_action(name, modifiers) + return function() + self.mouse_nav = false + self.modifiers = modifiers + self:maybe(name) + self.modifiers = nil + end +end + +function Menu:render() + local update_cursor = false + for _, menu in ipairs(self.all) do + if menu.fling then + update_cursor = update_cursor or menu.fling.update_cursor or false + local time_delta = state.render_last_time - menu.fling.time + local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1)) + self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu) + if progress < 1 then request_render() else menu.fling = nil end + end + end + if update_cursor then self:select_item_below_cursor() end + + cursor.on_primary_down = function() self:handle_cursor_down() end + cursor.on_primary_up = function() self:handle_cursor_up() end + if self.proximity_raw == 0 then + cursor.on_wheel_down = function() self:handle_wheel_down() end + cursor.on_wheel_up = function() self:handle_wheel_up() end + end + + local ass = assdraw.ass_new() + local opacity = options.menu_opacity * self.opacity + local spacing = self.item_padding + local icon_size = self.font_size + + function draw_menu(menu, x, y, opacity) + local ax, ay, bx, by = x, y, x + menu.width, y + menu.height + local draw_title = menu.is_root and menu.title + local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')' + local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1 + local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step) + local selected_index = menu.selected_index or -1 + -- remove menu_opacity to start off with full opacity, but still decay for parent menus + local text_opacity = opacity / options.menu_opacity + + -- Background + ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, { + color = bg, opacity = opacity, radius = 4, + }) + + for index = start_index, end_index, 1 do + local item = menu.items[index] + local next_item = menu.items[index + 1] + local is_highlighted = selected_index == index or item.active + local next_is_active = next_item and next_item.active + local next_is_highlighted = selected_index == index + 1 or next_is_active + + if not item then break end + + local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1) + local item_by = item_ay + self.item_height + local item_center_y = item_ay + (self.item_height / 2) + local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil + local content_ax, content_bx = ax + spacing, bx - spacing + local font_color = item.active and fgt or bgt + local shadow_color = item.active and fg or bg + + -- Separator + local separator_ay = item.separator and item_by - 1 or item_by + local separator_by = item_by + (item.separator and 2 or 1) + if is_highlighted then separator_ay = item_by + 1 end + if next_is_highlighted then separator_by = item_by end + if separator_by - separator_ay > 0 and item_by < by then + ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, { + color = fg, opacity = opacity * (item.separator and 0.08 or 0.06), + }) + end + + -- Highlight + local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0) + if highlight_opacity > 0 then + ass:rect(ax + 2, item_ay, bx - 2, item_by, { + radius = 2, color = fg, opacity = highlight_opacity * text_opacity, + clip = item_clip, + }) + end + + -- Icon + if item.icon then + local x, y = content_bx - (icon_size / 2), item_center_y + if item.icon == 'spinner' then + ass:spinner(x, y, icon_size * 1.5, {color = font_color, opacity = text_opacity * 0.8}) + else + ass:icon(x, y, icon_size * 1.5, item.icon, { + color = font_color, opacity = text_opacity, clip = item_clip, + shadow = 1, shadow_color = shadow_color, + }) + end + content_bx = content_bx - icon_size - spacing + end + + local title_cut_x = content_bx + if item.hint_width > 0 then + -- controls title & hint clipping proportional to the ratio of their widths + local title_content_ratio = item.title_width / (item.title_width + item.hint_width) + title_cut_x = round(content_ax + (content_bx - content_ax - spacing) * title_content_ratio + + (item.title_width > 0 and spacing / 2 or 0)) + end + + -- Hint + if item.hint then + item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint) + local clip = '\\clip(' .. title_cut_x .. ',' .. + math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')' + ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, { + size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * opacity, clip = clip, + shadow = 1, shadow_color = shadow_color, + }) + end + + -- Title + if item.title then + item.ass_safe_title = item.ass_safe_title or ass_escape(item.title) + local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ',' + .. title_cut_x .. ',' .. math.min(item_by, by) .. ')' + ass:txt(content_ax, item_center_y, 4, item.ass_safe_title, { + size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2, + opacity = text_opacity * (item.muted and 0.5 or 1), clip = clip, + shadow = 1, shadow_color = shadow_color, + }) + end + end + + -- Menu title + if draw_title then + local title_ay = ay - self.item_height + local title_height = self.item_height - 3 + menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title) + + -- Background + ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, { + color = fg, opacity = opacity * 0.8, radius = 2, + }) + ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', { + size = 80, color = bg, opacity = opacity * 0.1, + }) + + -- Title + ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.ass_safe_title, { + size = self.font_size, bold = true, color = bg, wrap = 2, opacity = opacity, + clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')', + }) + end + + -- Scrollbar + if menu.scroll_height > 0 then + local groove_height = menu.height - 2 + local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40) + local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height)) + ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {color = fg, opacity = opacity * 0.8}) + end + end + + -- Main menu + draw_menu(self.current, self.ax, self.ay, opacity) + + -- Parent menus + local parent_menu = self.current.parent_menu + local parent_offset_x = self.ax + local parent_opacity_factor = options.menu_parent_opacity + local menu_gap = 2 + + while parent_menu do + parent_offset_x = parent_offset_x - parent_menu.width - menu_gap + draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity) + parent_opacity_factor = parent_opacity_factor * parent_opacity_factor + parent_menu = parent_menu.parent_menu + end + + -- Selected menu + local selected_menu = self.current.items[self.current.selected_index] + + if selected_menu and selected_menu.items then + draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity) + end + + return ass +end + +return Menu diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua new file mode 100644 index 0000000..82a7e43 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua @@ -0,0 +1,80 @@ +local Element = require('uosc_shared/elements/Element') + +---@class PauseIndicator : Element +local PauseIndicator = class(Element) + +function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end +function PauseIndicator:init() + Element.init(self, 'pause_indicator') + self.ignores_menu = true + self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8 + self.paused = state.pause + self.type = options.pause_indicator + self.is_manual = options.pause_indicator == 'manual' + self.fadeout_requested = false + self.opacity = 0 + + mp.observe_property('pause', 'bool', function(_, paused) + if Elements.timeline.pressed then return end + if options.pause_indicator == 'flash' then + if self.paused == paused then return end + self:flash() + elseif options.pause_indicator == 'static' then + self:decide() + end + end) +end + +function PauseIndicator:flash() + if not self.is_manual and self.type ~= 'flash' then return end + -- can't wait for pause property event listener to set this, because when this is used inside a binding like: + -- cycle pause; script-binding uosc/flash-pause-indicator + -- the pause event is not fired fast enough, and indicator starts rendering with old icon + self.paused = mp.get_property_native('pause') + if self.is_manual then self.type = 'flash' end + self.opacity = 1 + self:tween_property('opacity', 1, 0, 0.15) +end + +-- decides whether static indicator should be visible or not +function PauseIndicator:decide() + if not self.is_manual and self.type ~= 'static' then return end + self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary + if self.is_manual then self.type = 'static' end + self.opacity = self.paused and 1 or 0 + request_render() + + -- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored. + -- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more + mp.add_timeout(.05, function() osd:update() end) +end + +function PauseIndicator:render() + if self.opacity == 0 then return end + + local ass = assdraw.ass_new() + local is_static = self.type == 'static' + + -- Background fadeout + if is_static then + ass:rect(0, 0, display.width, display.height, {color = bg, opacity = self.opacity * 0.3}) + end + + -- Icon + local size = round(math.min(display.width, display.height) * (is_static and 0.20 or 0.15)) + size = size + size * (1 - self.opacity) + + if self.paused then + ass:icon(display.width / 2, display.height / 2, size, 'pause', + {border = 1, opacity = self.base_icon_opacity * self.opacity} + ) + else + ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow', + {border = 1, opacity = self.base_icon_opacity * self.opacity} + ) + end + + return ass +end + +return PauseIndicator diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Speed.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Speed.lua new file mode 100644 index 0000000..6ea5097 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Speed.lua @@ -0,0 +1,192 @@ +local Element = require('uosc_shared/elements/Element') + +---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; } + +---@class Speed : Element +local Speed = class(Element) + +---@param props? ElementProps +function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end +function Speed:init(props) + Element.init(self, 'speed', props) + + self.width = 0 + self.height = 0 + self.notches = 10 + self.notch_every = 0.1 + ---@type number + self.notch_spacing = nil + ---@type number + self.font_size = nil + ---@type Dragging|nil + self.dragging = nil +end + +function Speed:on_coordinates() + self.height, self.width = self.by - self.ay, self.bx - self.ax + self.notch_spacing = self.width / (self.notches + 1) + self.font_size = round(self.height * 0.48 * options.font_scale) +end + +function Speed:speed_step(speed, up) + if options.speed_step_is_factor then + if up then + return speed * options.speed_step + else + return speed * 1 / options.speed_step + end + else + if up then + return speed + options.speed_step + else + return speed - options.speed_step + end + end +end + +function Speed:handle_cursor_down() + self:tween_stop() -- Stop and cleanup possible ongoing animations + self.dragging = { + start_time = mp.get_time(), + start_x = cursor.x, + distance = 0, + speed_distance = 0, + start_speed = state.speed, + } +end + +function Speed:on_global_mouse_move() + if not self.dragging then return end + + self.dragging.distance = cursor.x - self.dragging.start_x + self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every) + + local speed_current = state.speed + local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance + speed_drag_current = clamp(0.01, speed_drag_current, 100) + local drag_dir_up = speed_drag_current > speed_current + + local speed_step_next = speed_current + local speed_drag_diff = math.abs(speed_drag_current - speed_current) + while math.abs(speed_step_next - speed_current) < speed_drag_diff do + speed_step_next = self:speed_step(speed_step_next, drag_dir_up) + end + local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up) + + local speed_new = speed_step_prev + local speed_next_diff = math.abs(speed_drag_current - speed_step_next) + local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev) + if speed_next_diff < speed_prev_diff then + speed_new = speed_step_next + end + + if speed_new ~= speed_current then + mp.set_property_native('speed', speed_new) + end +end + +function Speed:handle_cursor_up() + if self.proximity_raw == 0 then + -- Reset speed on short clicks + if self.dragging and math.abs(self.dragging.distance) < 6 and mp.get_time() - self.dragging.start_time < 0.15 then + mp.set_property_native('speed', 1) + end + end + self.dragging = nil + request_render() +end + +function Speed:on_global_mouse_leave() + self.dragging = nil + request_render() +end + +function Speed:handle_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end +function Speed:handle_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end + +function Speed:render() + local visibility = self:get_visibility() + local opacity = self.dragging and 1 or visibility + + if opacity <= 0 then return end + + if self.proximity_raw == 0 then + cursor.on_primary_down = function() + self:handle_cursor_down() + cursor.on_primary_up = function() self:handle_cursor_up() end + end + cursor.on_wheel_down = function() self:handle_wheel_down() end + cursor.on_wheel_up = function() self:handle_wheel_up() end + end + if self.dragging then + cursor.on_primary_up = function() self:handle_cursor_up() end + end + + local ass = assdraw.ass_new() + + -- Background + ass:rect(self.ax, self.ay, self.bx, self.by, {color = bg, radius = 2, opacity = opacity * options.speed_opacity}) + + -- Coordinates + local ax, ay = self.ax, self.ay + local bx, by = self.bx, ay + self.height + local half_width = (self.width / 2) + local half_x = ax + half_width + + -- Notches + local speed_at_center = state.speed + if self.dragging then + speed_at_center = self.dragging.start_speed + self.dragging.speed_distance + speed_at_center = clamp(0.01, speed_at_center, 100) + end + local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every + local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing) + local guide_size = math.floor(self.height / 7.5) + local notch_by = by - guide_size + local notch_ay_big = ay + round(self.font_size * 1.1) + local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2) + local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4) + local from_to_index = math.floor(self.notches / 2) + + for i = -from_to_index, from_to_index do + local notch_speed = nearest_notch_speed + (i * self.notch_every) + + if notch_speed >= 0 and notch_speed <= 100 then + local notch_x = nearest_notch_x + (i * self.notch_spacing) + local notch_thickness = 1 + local notch_ay = notch_ay_small + if (notch_speed % (self.notch_every * 10)) < 0.00000001 then + notch_ay = notch_ay_big + notch_thickness = 1.5 + elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then + notch_ay = notch_ay_medium + end + + ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, { + color = fg, border = 1, border_color = bg, + opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity, + }) + end + end + + -- Center guide + ass:new_event() + ass:append('{\\rDefault\\an7\\blur0\\bord1\\shad0\\1c&H' .. fg .. '\\3c&H' .. bg .. '}') + ass:opacity(opacity) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(half_x, by - 2 - guide_size) + ass:line_to(half_x + guide_size, by - 2) + ass:line_to(half_x - guide_size, by - 2) + ass:draw_stop() + + -- Speed value + local speed_text = (round(state.speed * 100) / 100) .. 'x' + ass:txt(half_x, ay + (notch_ay_big - ay) / 2, 5, speed_text, { + size = self.font_size, color = bgt, border = options.text_border, border_color = bg, opacity = opacity, + }) + + return ass +end + +return Speed diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Timeline.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Timeline.lua new file mode 100644 index 0000000..8dfda9f --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Timeline.lua @@ -0,0 +1,430 @@ +local Element = require('uosc_shared/elements/Element') + +---@class Timeline : Element +local Timeline = class(Element) + +function Timeline:new() return Class.new(self) --[[@as Timeline]] end +function Timeline:init() + Element.init(self, 'timeline') + ---@type false|{pause: boolean, distance: number, last: {x: number, y: number}} + self.pressed = false + self.obstructed = false + self.size_max = 0 + self.size_min = 0 + self.size_min_override = options.timeline_start_hidden and 0 or nil + self.font_size = 0 + self.top_border = options.timeline_border + self.is_hovered = false + self.has_thumbnail = false + + -- Delayed seeking timer + self.seek_timer = mp.add_timeout(0.05, function() self:set_from_cursor() end) + self.seek_timer:kill() + + -- Release any dragging when file gets unloaded + mp.register_event('end-file', function() self.pressed = false end) +end + +function Timeline:get_visibility() + return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self)) + or Element.get_visibility(self) +end + +function Timeline:decide_enabled() + local previous = self.enabled + self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil + if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end +end + +function Timeline:get_effective_size_min() + return self.size_min_override or self.size_min +end + +function Timeline:get_effective_size() + if Elements.speed and Elements.speed.dragging then return self.size_max end + local size_min = self:get_effective_size_min() + return size_min + math.ceil((self.size_max - size_min) * self:get_visibility()) +end + +function Timeline:get_effective_line_width() + return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width +end + +function Timeline:get_is_hovered() return self.enabled and self.is_hovered end + +function Timeline:update_dimensions() + if state.fullormaxed then + self.size_min = options.timeline_size_min_fullscreen + self.size_max = options.timeline_size_max_fullscreen + else + self.size_min = options.timeline_size_min + self.size_max = options.timeline_size_max + end + self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale) + self.ax = Elements.window_border.size + self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border + self.bx = display.width - Elements.window_border.size + self.by = display.height - Elements.window_border.size + self.width = self.bx - self.ax + self.chapter_size = math.max((self.by - self.ay) / 10, 3) + self.chapter_size_hover = self.chapter_size * 2 + + -- Disable if not enough space + local available_space = display.height - Elements.window_border.size * 2 + if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end + self.obstructed = available_space < self.size_max + 10 + self:decide_enabled() +end + +function Timeline:get_time_at_x(x) + local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() - 1 or 0) + local time_width = self.width - line_width - 1 + local fax = (time_width) * state.time / state.duration + local fbx = fax + line_width + -- time starts 0.5 pixels in + x = x - self.ax - 0.5 + if x > fbx then x = x - line_width + elseif x > fax then x = fax end + local progress = clamp(0, x / time_width, 1) + return state.duration * progress +end + +---@param fast? boolean +function Timeline:set_from_cursor(fast) + if state.time and state.duration then + mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact') + end +end + +function Timeline:clear_thumbnail() + mp.commandv('script-message-to', 'thumbfast', 'clear') + self.has_thumbnail = false +end + +function Timeline:handle_cursor_down() + self.pressed = {pause = state.pause, distance = 0, last = {x = cursor.x, y = cursor.y}} + mp.set_property_native('pause', true) + self:set_from_cursor() + cursor.on_primary_up = function() self:handle_cursor_up() end +end +function Timeline:on_prop_duration() self:decide_enabled() end +function Timeline:on_prop_time() self:decide_enabled() end +function Timeline:on_prop_border() self:update_dimensions() end +function Timeline:on_prop_fullormaxed() self:update_dimensions() end +function Timeline:on_display() self:update_dimensions() end +function Timeline:handle_cursor_up() + self.seek_timer:kill() + if self.pressed then + mp.set_property_native('pause', self.pressed.pause) + self.pressed = false + end +end +function Timeline:on_global_mouse_leave() + self.pressed = false +end + +function Timeline:on_global_mouse_move() + if self.pressed then + self.pressed.distance = self.pressed.distance + get_point_to_point_proximity(self.pressed.last, cursor) + self.pressed.last.x, self.pressed.last.y = cursor.x, cursor.y + if self.width / state.duration < 10 then + self:set_from_cursor(true) + self.seek_timer:kill() + self.seek_timer:resume() + else self:set_from_cursor() end + end +end +function Timeline:handle_wheel_up() mp.commandv('seek', options.timeline_step) end +function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) end + +function Timeline:render() + if self.size_max == 0 then return end + + local size_min = self:get_effective_size_min() + local size = self:get_effective_size() + local visibility = self:get_visibility() + self.is_hovered = false + + if size < 1 then + if self.has_thumbnail then self:clear_thumbnail() end + return + end + + if self.proximity_raw == 0 then + self.is_hovered = true + cursor.on_primary_down = function() self:handle_cursor_down() end + cursor.on_wheel_down = function() self:handle_wheel_down() end + cursor.on_wheel_up = function() self:handle_wheel_up() end + end + + if self.pressed then + cursor.on_primary_up = function() self:handle_cursor_up() end + end + + local ass = assdraw.ass_new() + + -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min + local hide_text_below = math.max(self.font_size * 0.8, size_min * 2) + local hide_text_ramp = hide_text_below / 2 + local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp + + local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4) + local progress = state.time / state.duration + local is_line = options.timeline_style == 'line' + + -- Foreground & Background bar coordinates + local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by + local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby + local fcy = fay + (size / 2) + + local line_width = 0 + + if is_line then + local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1) + local line_width_max = self:get_effective_line_width() + local max_min_width_delta = size_min > 0 + and line_width_max - line_width_max * options.timeline_line_width_minimized_scale + or 0 + line_width = line_width_max - (max_min_width_delta * minimized_fraction) + fax = bax + (self.width - line_width) * progress + fbx = fax + line_width + line_width = line_width - 1 + else + fax, fbx = bax, bax + self.width * progress + end + + local foreground_size = fby - fay + local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping + + -- time starts 0.5 pixels in + local time_ax = bax + 0.5 + local time_width = self.width - line_width - 1 + + -- time to x: calculates x coordinate so that it never lies inside of the line + local function t2x(time) + local x = time_ax + time_width * time / state.duration + return time <= state.time and x or x + line_width + end + + -- Background + ass:new_event() + ass:pos(0, 0) + ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}') + ass:opacity(options.timeline_opacity) + ass:draw_start() + ass:rect_cw(bax, bay, fax, bby) --left of progress + ass:rect_cw(fbx, bay, bbx, bby) --right of progress + ass:rect_cw(fax, bay, fbx, fay) --above progress + ass:draw_stop() + + -- Progress + ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity}) + + -- Uncached ranges + local buffered_playtime = nil + if state.uncached_ranges then + local opts = {size = 80, anchor_y = fby} + local texture_char = visibility > 0 and 'b' or 'a' + local offset = opts.size / (visibility > 0 and 24 or 28) + for _, range in ipairs(state.uncached_ranges) do + if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then + buffered_playtime = (range[1] - state.time) / (state.speed or 1) + end + if options.timeline_cache then + local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1])) + local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2])) + opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax + ass:texture(ax, fay, bx, fby, texture_char, opts) + opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset + ass:texture(ax, fay, bx, fby, texture_char, opts) + end + end + end + + -- Custom ranges + for _, chapter_range in ipairs(state.chapter_ranges) do + local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start) + local rbx = chapter_range['end'] > state.duration - 0.1 and bbx + or t2x(math.min(chapter_range['end'], state.duration)) + ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity}) + end + + -- Chapters + local hovered_chapter = nil + if (options.timeline_chapters_opacity > 0 + and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b) + ) then + local diamond_radius = foreground_size < 3 and foreground_size or self.chapter_size + local diamond_radius_hovered = diamond_radius * 2 + local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1 + + if diamond_radius > 0 then + local function draw_chapter(time, radius) + local chapter_x, chapter_y = t2x(time), fay - 1 + ass:new_event() + ass:append(string.format( + '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}', + diamond_border, fg, bg, bg, opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity) + )) + ass:draw_start() + ass:move_to(chapter_x - radius, chapter_y) + ass:line_to(chapter_x, chapter_y - radius) + ass:line_to(chapter_x + radius, chapter_y) + ass:line_to(chapter_x, chapter_y + radius) + ass:draw_stop() + end + + if #state.chapters > 0 then + -- Find hovered chapter indicator + local closest_delta = INFINITY + + if self.proximity_raw < diamond_radius_hovered then + for i, chapter in ipairs(state.chapters) do + local chapter_x, chapter_y = t2x(chapter.time), fay - 1 + local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2) + if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then + hovered_chapter, closest_delta = chapter, cursor_chapter_delta + self.is_hovered = true + cursor.on_primary_down = function() + mp.commandv('seek', hovered_chapter.time, 'absolute+exact') + end + end + end + end + + for i, chapter in ipairs(state.chapters) do + if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end + end + + -- Render hovered chapter above others + if hovered_chapter then draw_chapter(hovered_chapter.time, diamond_radius_hovered) end + end + + -- A-B loop indicators + local has_a, has_b = state.ab_loop_a and state.ab_loop_a >= 0, state.ab_loop_b and state.ab_loop_b > 0 + local ab_radius = round(math.min(math.max(8, foreground_size * 0.25), foreground_size)) + + ---@param time number + ---@param kind 'a'|'b' + local function draw_ab_indicator(time, kind) + local x = t2x(time) + ass:new_event() + ass:append(string.format( + '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}', + diamond_border, fg, bg, bg, opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity) + )) + ass:draw_start() + ass:move_to(x, fby - ab_radius) + if kind == 'b' then ass:line_to(x + 3, fby - ab_radius) end + ass:line_to(x + (kind == 'a' and 0 or ab_radius), fby) + ass:line_to(x - (kind == 'b' and 0 or ab_radius), fby) + if kind == 'a' then ass:line_to(x - 3, fby - ab_radius) end + ass:draw_stop() + end + + if has_a then draw_ab_indicator(state.ab_loop_a, 'a') end + if has_b then draw_ab_indicator(state.ab_loop_b, 'b') end + end + end + + local function draw_timeline_text(x, y, align, text, opts) + opts.color, opts.border_color = fgt, fg + opts.clip = '\\clip(' .. foreground_coordinates .. ')' + ass:txt(x, y, align, text, opts) + opts.color, opts.border_color = bgt, bg + opts.clip = '\\iclip(' .. foreground_coordinates .. ')' + ass:txt(x, y, align, text, opts) + end + + -- Time values + if text_opacity > 0 then + local time_opts = {size = self.font_size, opacity = text_opacity, border = 2} + -- Upcoming cache time + if buffered_playtime and options.buffered_time_threshold > 0 + and buffered_playtime < options.buffered_time_threshold then + local x, align = fbx + 5, 4 + local cache_opts = {size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = 1} + local human = round(math.max(buffered_playtime, 0)) .. 's' + local width = text_width(human, cache_opts) + local time_width = timestamp_width(state.time_human, time_opts) + local time_width_end = timestamp_width(state.destination_time_human, time_opts) + local min_x, max_x = bax + spacing + 5 + time_width, bbx - spacing - 5 - time_width_end + if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end + draw_timeline_text(x, fcy, align, human, cache_opts) + end + + -- Elapsed time + if state.time_human then + draw_timeline_text(bax + spacing, fcy, 4, state.time_human, time_opts) + end + + -- End time + if state.destination_time_human then + draw_timeline_text(bbx - spacing, fcy, 6, state.destination_time_human, time_opts) + end + end + + -- Hovered time and chapter + local rendered_thumbnail = false + if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and + not (Elements.speed and Elements.speed.dragging) then + local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x + local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x) + + -- Cursor line + -- 0.5 to switch when the pixel is half filled in + local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg + local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby + ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2}) + local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by} + + -- Timestamp + local offset = #state.chapters > 0 and 10 or 4 + local opts = {size = self.font_size, offset = offset} + local hovered_time_human = format_time(hovered_seconds, state.duration) + opts.width_overwrite = timestamp_width(hovered_time_human, opts) + ass:tooltip(tooltip_anchor, hovered_time_human, opts) + tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - offset + + -- Thumbnail + if not thumbnail.disabled + and (not self.pressed or self.pressed.distance < 5) + and thumbnail.width ~= 0 + and thumbnail.height ~= 0 + then + local scale_x, scale_y = display.scale_x, display.scale_y + local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y) + local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y + local thumb_width, thumb_height = thumbnail.width, thumbnail.height + local thumb_x = round(clamp( + thumb_x_margin, cursor_x * scale_x - thumb_width / 2, + display.width * scale_x - thumb_width - thumb_x_margin + )) + local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height) + local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y + local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y + ass:rect(ax, ay, bx, by, {color = bg, border = 1, border_color = fg, border_opacity = 0.08, radius = 2}) + mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y) + self.has_thumbnail, rendered_thumbnail = true, true + tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay + end + + -- Chapter title + if #state.chapters > 0 then + local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true) + if chapter and not chapter.is_end_only then + ass:tooltip(tooltip_anchor, chapter.title_wrapped, { + size = self.font_size, offset = 10, responsive = false, bold = true, + width_overwrite = chapter.title_wrapped_width * self.font_size, + }) + end + end + end + + -- Clear thumbnail + if not rendered_thumbnail and self.has_thumbnail then self:clear_thumbnail() end + + return ass +end + +return Timeline diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/TopBar.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/TopBar.lua new file mode 100644 index 0000000..514def2 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/TopBar.lua @@ -0,0 +1,253 @@ +local Element = require('uosc_shared/elements/Element') + +---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()} + +---@class TopBarButton : Element +local TopBarButton = class(Element) + +---@param id string +---@param props TopBarButtonProps +function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end +function TopBarButton:init(id, props) + Element.init(self, id, props) + self.anchor_id = 'top_bar' + self.icon = props.icon + self.background = props.background + self.command = props.command +end + +function TopBarButton:handle_cursor_down() + mp.command(type(self.command) == 'function' and self.command() or self.command) +end + +function TopBarButton:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + local ass = assdraw.ass_new() + + -- Background on hover + if self.proximity_raw == 0 then + ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility}) + cursor.on_primary_down = function() self:handle_cursor_down() end + end + + local width, height = self.bx - self.ax, self.by - self.ay + local icon_size = math.min(width, height) * 0.5 + ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, { + opacity = visibility, border = options.text_border, + }) + + return ass +end + +--[[ TopBar ]] + +---@class TopBar : Element +local TopBar = class(Element) + +function TopBar:new() return Class.new(self) --[[@as TopBar]] end +function TopBar:init() + Element.init(self, 'top_bar') + self.size = 0 + self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1 + self.show_alt_title = false + self.main_title, self.alt_title = nil, nil + + local function get_maximized_command() + return state.border + and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized') + or 'set window-maximized no;cycle fullscreen' + end + + -- Order aligns from right to left + self.buttons = { + TopBarButton:new('tb_close', {icon = 'close', background = '2311e8', command = 'quit'}), + TopBarButton:new('tb_max', {icon = 'crop_square', background = '222222', command = get_maximized_command}), + TopBarButton:new('tb_min', {icon = 'minimize', background = '222222', command = 'cycle window-minimized'}), + } + + self:decide_titles() +end + +function TopBar:decide_enabled() + if options.top_bar == 'no-border' then + self.enabled = not state.border or state.fullscreen + else + self.enabled = options.top_bar == 'always' + end + self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title) + for _, element in ipairs(self.buttons) do + element.enabled = self.enabled and options.top_bar_controls + end +end + +function TopBar:decide_titles() + self.alt_title = state.alt_title ~= '' and state.alt_title or nil + self.main_title = state.title ~= '' and state.title or nil + + -- Fall back to alt title if main is empty + if not self.main_title then + self.main_title, self.alt_title = self.alt_title, nil + end + + -- Deduplicate the main and alt titles by checking if one completely + -- contains the other, and using only the longer one. + if self.main_title and self.alt_title and not self.show_alt_title then + local longer_title, shorter_title + if #self.main_title < #self.alt_title then + longer_title, shorter_title = self.alt_title, self.main_title + else + longer_title, shorter_title = self.main_title, self.alt_title + end + + local escaped_shorter_title = string.gsub(shorter_title --[[@as string]], "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1") + if string.match(longer_title --[[@as string]], escaped_shorter_title) then + self.main_title, self.alt_title = longer_title, nil + end + end +end + +function TopBar:update_dimensions() + self.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size + self.icon_size = round(self.size * 0.5) + self.spacing = math.ceil(self.size * 0.25) + self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale) + self.button_width = round(self.size * 1.15) + self.ay = Elements.window_border.size + self.bx = display.width - Elements.window_border.size + self.by = self.size + Elements.window_border.size + self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0) + self.ax = options.top_bar_title and Elements.window_border.size or self.title_bx + + local button_bx = self.bx + for _, element in pairs(self.buttons) do + element.ax, element.bx = button_bx - self.button_width, button_bx + element.ay, element.by = self.ay, self.by + button_bx = button_bx - self.button_width + end +end + +function TopBar:toggle_title() + if options.top_bar_alt_title_place ~= 'toggle' then return end + self.show_alt_title = not self.show_alt_title +end + +function TopBar:on_prop_title() self:decide_titles() end +function TopBar:on_prop_alt_title() self:decide_titles() end + +function TopBar:on_prop_border() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_prop_fullscreen() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_prop_maximized() + self:decide_enabled() + self:update_dimensions() +end + +function TopBar:on_display() self:update_dimensions() end + +function TopBar:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + local ass = assdraw.ass_new() + + -- Window title + if options.top_bar_title and (state.title or state.has_playlist) then + local bg_margin = math.floor((self.size - self.font_size) / 4) + local padding = self.font_size / 2 + local title_ax = self.ax + bg_margin + local title_ay = self.ay + bg_margin + local max_bx = self.title_bx - self.spacing + + -- Playlist position + if state.has_playlist then + local text = state.playlist_pos .. '' .. state.playlist_count + local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/' + .. state.playlist_count + local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility} + local bx = round(title_ax + text_width(text, opts) + padding * 2) + ass:rect(title_ax, title_ay, bx, self.by - bg_margin, {color = fg, opacity = visibility, radius = 2}) + ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, opts) + title_ax = bx + bg_margin + local rect = {ax = self.ax, ay = self.ay, bx = bx, by = self.by} + + if get_point_to_rectangle_proximity(cursor, rect) == 0 then + cursor.on_primary_down = function() mp.command('script-binding uosc/playlist') end + end + end + + -- Skip rendering titles if there's not enough horizontal space + if max_bx - title_ax > self.font_size * 3 then + -- Main title + local main_title = self.show_alt_title and self.alt_title or self.main_title + if main_title then + local opts = { + size = self.font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility, + clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by), + } + local bx = math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2) + local by = self.by - bg_margin + local rect = {ax = title_ax, ay = self.ay, bx = self.title_bx, by = self.by} + + if get_point_to_rectangle_proximity(cursor, rect) == 0 then + cursor.on_primary_down = function() self:toggle_title() end + end + + ass:rect(title_ax, title_ay, bx, by, { + color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2, + }) + ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, main_title, opts) + title_ay = by + 1 + end + + -- Alt title + if self.alt_title and options.top_bar_alt_title_place == 'below' then + local font_size = self.font_size * 0.9 + local height = font_size * 1.3 + local by = title_ay + height + local opts = { + size = font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility + } + local bx = math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2) + opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by) + ass:rect(title_ax, title_ay, bx, by, { + color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2, + }) + ass:txt(title_ax + padding, title_ay + height / 2, 4, self.alt_title, opts) + title_ay = by + 1 + end + + -- Subtitle: current chapter + if state.current_chapter then + local font_size = self.font_size * 0.8 + local height = font_size * 1.3 + local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title + local by = title_ay + height + local opts = { + size = font_size, italic = true, wrap = 2, color = bgt, + border = 1, border_color = bg, opacity = visibility * 0.8, + } + local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2) + opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by) + ass:rect(title_ax, title_ay, bx, by, { + color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2, + }) + ass:txt(title_ax + padding, title_ay + height / 2, 4, text, opts) + title_ay = by + 1 + end + end + self.title_by = title_ay - 1 + else + self.title_by = self.ay + end + + return ass +end + +return TopBar diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/Volume.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/Volume.lua new file mode 100644 index 0000000..2f591b6 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/Volume.lua @@ -0,0 +1,252 @@ +local Element = require('uosc_shared/elements/Element') + +--[[ MuteButton ]] + +---@class MuteButton : Element +local MuteButton = class(Element) +---@param props? ElementProps +function MuteButton:new(props) return Class.new(self, 'volume_mute', props) --[[@as MuteButton]] end +function MuteButton:get_visibility() return Elements.volume:get_visibility(self) end +function MuteButton:render() + local visibility = self:get_visibility() + if visibility <= 0 then return end + if self.proximity_raw == 0 then + cursor.on_primary_down = function() mp.commandv('cycle', 'mute') end + end + local ass = assdraw.ass_new() + local icon_name = state.mute and 'volume_off' or 'volume_up' + local width = self.bx - self.ax + ass:icon(self.ax + (width / 2), self.by, width * 0.7, icon_name, + {border = options.text_border, opacity = options.volume_opacity * visibility, align = 2} + ) + return ass +end + +--[[ VolumeSlider ]] + +---@class VolumeSlider : Element +local VolumeSlider = class(Element) +---@param props? ElementProps +function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end +function VolumeSlider:init(props) + Element.init(self, 'volume_slider', props) + self.pressed = false + self.nudge_y = 0 -- vertical position where volume overflows 100 + self.nudge_size = 0 + self.draw_nudge = false + self.spacing = 0 + self.radius = 1 +end + +function VolumeSlider:get_visibility() return Elements.volume:get_visibility(self) end + +function VolumeSlider:set_volume(volume) + volume = round(volume / options.volume_step) * options.volume_step + if state.volume == volume then return end + mp.commandv('set', 'volume', clamp(0, volume, state.volume_max)) +end + +function VolumeSlider:set_from_cursor() + local volume_fraction = (self.by - cursor.y - options.volume_border) / (self.by - self.ay - options.volume_border) + self:set_volume(volume_fraction * state.volume_max) +end + +function VolumeSlider:on_coordinates() + if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end + local width = self.bx - self.ax + self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max)) + self.nudge_size = round(width * 0.18) + self.draw_nudge = self.ay < self.nudge_y + self.spacing = round(width * 0.2) + self.radius = math.max(2, (self.bx - self.ax) / 10) +end +function VolumeSlider:on_global_mouse_move() + if self.pressed then self:set_from_cursor() end +end +function VolumeSlider:handle_wheel_up() self:set_volume(state.volume + options.volume_step) end +function VolumeSlider:handle_wheel_down() self:set_volume(state.volume - options.volume_step) end + +function VolumeSlider:render() + local visibility = self:get_visibility() + local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by + local width, height = bx - ax, by - ay + + if width <= 0 or height <= 0 or visibility <= 0 then return end + + if self.proximity_raw == 0 then + cursor.on_primary_down = function() + self.pressed = true + self:set_from_cursor() + cursor.on_primary_up = function() self.pressed = false end + end + cursor.on_wheel_down = function() self:handle_wheel_down() end + cursor.on_wheel_up = function() self:handle_wheel_up() end + end + if self.pressed then cursor.on_primary_up = function() + self.pressed = false end + end + + local ass = assdraw.ass_new() + local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -INFINITY, self.nudge_size + local volume_y = self.ay + options.volume_border + + ((height - (options.volume_border * 2)) * (1 - math.min(state.volume / state.volume_max, 1))) + + -- Draws a rectangle with nudge at requested position + ---@param p number Padding from slider edges. + ---@param cy? number A y coordinate where to clip the path from the bottom. + function create_nudged_path(p, cy) + cy = cy or ay + p + local ax, bx, by = ax + p, bx - p, by - p + local r = math.max(1, self.radius - p) + local d, rh = r * 2, r / 2 + local nudge_size = ((QUARTER_PI_SIN * (nudge_size - p)) + p) / QUARTER_PI_SIN + local path = assdraw.ass_new() + path:move_to(bx - r, by) + path:line_to(ax + r, by) + if cy > by - d then + local subtracted_radius = (d - (cy - (by - d))) / 2 + local xbd = (r - subtracted_radius * 1.35) -- x bezier delta + path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by) + else + path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r) + local nudge_bottom_y = nudge_y + nudge_size + + if cy + rh <= nudge_bottom_y then + path:line_to(ax, nudge_bottom_y) + if cy <= nudge_y then + path:line_to((ax + nudge_size), nudge_y) + local nudge_top_y = nudge_y - nudge_size + if cy <= nudge_top_y then + local r, rh = r, rh + if cy > nudge_top_y - r then + r = nudge_top_y - cy + rh = r / 2 + end + path:line_to(ax, nudge_top_y) + path:line_to(ax, cy + r) + path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r) + path:line_to(bx, nudge_top_y) + else + local triangle_side = cy - nudge_top_y + path:line_to((ax + triangle_side), cy) + path:line_to((bx - triangle_side), cy) + end + path:line_to((bx - nudge_size), nudge_y) + else + local triangle_side = nudge_bottom_y - cy + path:line_to((ax + triangle_side), cy) + path:line_to((bx - triangle_side), cy) + end + path:line_to(bx, nudge_bottom_y) + else + path:line_to(ax, cy + r) + path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy) + path:line_to(bx - r, cy) + path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r) + end + path:line_to(bx, by - r) + path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by) + end + return path + end + + -- BG & FG paths + local bg_path = create_nudged_path(0) + local fg_path = create_nudged_path(options.volume_border, volume_y) + + -- Background + ass:new_event() + ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. + '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}') + ass:opacity(options.volume_opacity, visibility) + ass:pos(0, 0) + ass:draw_start() + ass:append(bg_path.text) + ass:draw_stop() + + -- Foreground + ass:new_event() + ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. fg .. '}') + ass:opacity(options.volume_opacity, visibility) + ass:pos(0, 0) + ass:draw_start() + ass:append(fg_path.text) + ass:draw_stop() + + -- Current volume value + local volume_string = tostring(round(state.volume * 10) / 10) + local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale) + if volume_y < self.by - self.spacing then + ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, { + size = font_size, color = fgt, opacity = visibility, + clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + if volume_y > self.by - self.spacing - font_size then + ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, { + size = font_size, color = bgt, opacity = visibility, + clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')', + }) + end + + -- Disabled stripes for no audio + if not state.has_audio then + local fg_100_path = create_nudged_path(options.volume_border) + local texture_opts = { + size = 200, color = 'ffffff', opacity = visibility * 0.1, anchor_x = ax, + clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')', + } + ass:texture(ax, ay, bx, by, 'a', texture_opts) + texture_opts.color = '000000' + texture_opts.anchor_x = ax + texture_opts.size / 28 + ass:texture(ax, ay, bx, by, 'a', texture_opts) + end + + return ass +end + +--[[ Volume ]] + +---@class Volume : Element +local Volume = class(Element) + +function Volume:new() return Class.new(self) --[[@as Volume]] end +function Volume:init() + Element.init(self, 'volume') + self.mute = MuteButton:new({anchor_id = 'volume'}) + self.slider = VolumeSlider:new({anchor_id = 'volume'}) +end + +function Volume:get_visibility() + return self.slider.pressed and 1 or Elements.timeline:get_is_hovered() and -1 or Element.get_visibility(self) +end + +function Volume:update_dimensions() + local width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size + local controls, timeline, top_bar = Elements.controls, Elements.timeline, Elements.top_bar + local min_y = top_bar.enabled and top_bar.by or 0 + local max_y = (controls and controls.enabled and controls.ay) or (timeline.enabled and timeline.ay) + or display.height - top_bar.size + local available_height = max_y - min_y + local max_height = available_height * 0.8 + local height = round(math.min(width * 8, max_height)) + self.enabled = height > width * 2 -- don't render if too small + local margin = (width / 2) + Elements.window_border.size + self.ax = round(options.volume == 'left' and margin or display.width - margin - width) + self.ay = min_y + round((available_height - height) / 2) + self.bx = round(self.ax + width) + self.by = round(self.ay + height) + self.mute.enabled, self.slider.enabled = self.enabled, self.enabled + self.mute:set_coordinates(self.ax, self.by - round(width * 0.8), self.bx, self.by) + self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute.ay) +end + +function Volume:on_display() self:update_dimensions() end +function Volume:on_prop_border() self:update_dimensions() end +function Volume:on_controls_reflow() self:update_dimensions() end + +return Volume diff --git a/multimedia/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua b/multimedia/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua new file mode 100644 index 0000000..c5544f5 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua @@ -0,0 +1,33 @@ +local Element = require('uosc_shared/elements/Element') + +---@class WindowBorder : Element +local WindowBorder = class(Element) + +function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end +function WindowBorder:init() + Element.init(self, 'window_border') + self.ignores_menu = true + self.size = 0 +end + +function WindowBorder:decide_enabled() + self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border + self.size = self.enabled and options.window_border_size or 0 +end + +function WindowBorder:on_prop_border() self:decide_enabled() end +function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end + +function WindowBorder:render() + if self.size > 0 then + local ass = assdraw.ass_new() + local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' .. + (display.width - self.size) .. ',' .. (display.height - self.size) .. ')' + ass:rect(0, 0, display.width + 1, display.height + 1, { + color = bg, clip = clip, opacity = options.window_border_opacity, + }) + return ass + end +end + +return WindowBorder diff --git a/multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua b/multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua new file mode 100644 index 0000000..108953f --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/lib/ass.lua @@ -0,0 +1,170 @@ +--[[ ASSDRAW EXTENSIONS ]] + +local ass_mt = getmetatable(assdraw.ass_new()) + +-- Opacity. +---@param opacity number|number[] Opacity of all elements, or an array of [primary, secondary, border, shadow] opacities. +---@param fraction? number Optionally adjust the above opacity by this fraction. +function ass_mt:opacity(opacity, fraction) + fraction = fraction ~= nil and fraction or 1 + if type(opacity) == 'number' then + self.text = self.text .. string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction)) + else + self.text = self.text .. string.format( + '{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}', + opacity_to_alpha((opacity[1] or 0) * fraction), + opacity_to_alpha((opacity[2] or 0) * fraction), + opacity_to_alpha((opacity[3] or 0) * fraction), + opacity_to_alpha((opacity[4] or 0) * fraction) + ) + end +end + +-- Icon. +---@param x number +---@param y number +---@param size number +---@param name string +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number} +function ass_mt:icon(x, y, size, name, opts) + opts = opts or {} + opts.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false + self:txt(x, y, opts.align or 5, name, opts) +end + +-- Text. +-- Named `txt` because `ass.text` is a value. +---@param x number +---@param y number +---@param align number +---@param value string|number +---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; rotate?: number; wrap?: number; opacity?: number; clip?: string} +function ass_mt:txt(x, y, align, value, opts) + local border_size = opts.border or 0 + local shadow_size = opts.shadow or 0 + local tags = '\\pos(' .. x .. ',' .. y .. ')\\rDefault\\an' .. align .. '\\blur0' + -- font + tags = tags .. '\\fn' .. (opts.font or config.font) + -- font size + tags = tags .. '\\fs' .. opts.size + -- bold + if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end + -- italic + if opts.italic then tags = tags .. '\\i1' end + -- rotate + if opts.rotate then tags = tags .. '\\frz' .. opts.rotate end + -- wrap + if opts.wrap then tags = tags .. '\\q' .. opts.wrap end + -- border + tags = tags .. '\\bord' .. border_size + -- shadow + tags = tags .. '\\shad' .. shadow_size + -- colors + tags = tags .. '\\1c&H' .. (opts.color or bgt) + if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end + if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) end + -- opacity + if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end + -- clip + if opts.clip then tags = tags .. opts.clip end + -- render + self:new_event() + self.text = self.text .. '{' .. tags .. '}' .. value +end + +-- Tooltip. +---@param element {ax: number; ay: number; bx: number; by: number} +---@param value string|number +---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, responsive?: boolean} +function ass_mt:tooltip(element, value, opts) + opts = opts or {} + opts.size = opts.size or 16 + opts.border = options.text_border + opts.border_color = bg + local offset = opts.offset or opts.size / 2 + local align_top = opts.responsive == false or element.ay - offset > opts.size * 2 + local x = element.ax + (element.bx - element.ax) / 2 + local y = align_top and element.ay - offset or element.by + offset + local margin = (opts.width_overwrite or text_width(value, opts)) / 2 + 10 + self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts) +end + +-- Rectangle. +---@param ax number +---@param ay number +---@param bx number +---@param by number +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; border_opacity?: number; clip?: string, radius?: number} +function ass_mt:rect(ax, ay, bx, by, opts) + opts = opts or {} + local border_size = opts.border or 0 + local tags = '\\pos(0,0)\\rDefault\\an7\\blur0' + -- border + tags = tags .. '\\bord' .. border_size + -- colors + tags = tags .. '\\1c&H' .. (opts.color or fg) + if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end + -- opacity + if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end + if opts.border_opacity then tags = tags .. string.format('\\3a&H%X&', opacity_to_alpha(opts.border_opacity)) end + -- clip + if opts.clip then + tags = tags .. opts.clip + end + -- draw + self:new_event() + self.text = self.text .. '{' .. tags .. '}' + self:draw_start() + if opts.radius then + self:round_rect_cw(ax, ay, bx, by, opts.radius) + else + self:rect_cw(ax, ay, bx, by) + end + self:draw_stop() +end + +-- Circle. +---@param x number +---@param y number +---@param radius number +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string} +function ass_mt:circle(x, y, radius, opts) + opts = opts or {} + opts.radius = radius + self:rect(x - radius, y - radius, x + radius, y + radius, opts) +end + +-- Texture. +---@param ax number +---@param ay number +---@param bx number +---@param by number +---@param char string Texture font character. +---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number} +function ass_mt:texture(ax, ay, bx, by, char, opts) + opts = opts or {} + local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay + local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')') + local tile_size, opacity = opts.size or 100, opts.opacity or 0.2 + local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size + local width, height = bx - x, by - y + local line = string.rep(char, math.ceil((width / tile_size))) + local lines = '' + for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end + self:txt( + x, y, 7, lines, + {font = 'uosc_textures', size = tile_size, color = opts.color, bold = false, opacity = opacity, clip = clip}) +end + +-- Rotating spinner icon. +---@param x number +---@param y number +---@param size number +---@param opts? {color?: string; opacity?: number; clip?: string; border?: number; border_color?: string;} +function ass_mt:spinner(x, y, size, opts) + opts = opts or {} + opts.rotate = (state.render_last_time * 1.75 % 1) * -360 + opts.color = opts.color or fg + self:icon(x, y, size, 'autorenew', opts) + request_render() +end diff --git a/multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua b/multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua new file mode 100644 index 0000000..5b7b790 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/lib/menus.lua @@ -0,0 +1,292 @@ +---@param data MenuData +---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]} +function open_command_menu(data, opts) + local function run_command(command) + if type(command) == 'string' then + mp.command(command) + else + ---@diagnostic disable-next-line: deprecated + mp.commandv(unpack(command)) + end + end + ---@type MenuOptions + local menu_opts = {} + if opts then + menu_opts.mouse_nav = opts.mouse_nav + if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end + end + local menu = Menu:open(data, run_command, menu_opts) + if opts and opts.submenu then menu:activate_submenu(opts.submenu) end + return menu +end + +---@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]} +function toggle_menu_with_items(opts) + if Menu:is_open('menu') then Menu:close() + else open_command_menu({type = 'menu', items = config.menu_items}, opts) end +end + +---@param options {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} +function create_self_updating_menu_opener(options) + return function() + if Menu:is_open(options.type) then Menu:close() return end + local list = mp.get_property_native(options.list_prop) + local active = options.active_prop and mp.get_property_native(options.active_prop) or nil + local menu + + local function update() menu:update_items(options.serializer(list, active)) end + + local ignore_initial_list = true + local function handle_list_prop_change(name, value) + if ignore_initial_list then ignore_initial_list = false + else list = value update() end + end + + local ignore_initial_active = true + local function handle_active_prop_change(name, value) + if ignore_initial_active then ignore_initial_active = false + else active = value update() end + end + + local initial_items, selected_index = options.serializer(list, active) + + -- Items and active_index are set in the handle_prop_change callback, since adding + -- a property observer triggers its handler immediately, we just let that initialize the items. + menu = Menu:open( + {type = options.type, title = options.title, items = initial_items, selected_index = selected_index}, + options.on_select, { + on_open = function() + mp.observe_property(options.list_prop, 'native', handle_list_prop_change) + if options.active_prop then + mp.observe_property(options.active_prop, 'native', handle_active_prop_change) + end + end, + on_close = function() + mp.unobserve_property(handle_list_prop_change) + mp.unobserve_property(handle_active_prop_change) + end, + on_move_item = options.on_move_item, + on_delete_item = options.on_delete_item, + }) + end +end + +function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command) + local function serialize_tracklist(tracklist) + local items = {} + + if load_command then + items[#items + 1] = { + title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true, + } + end + + local first_item_index = #items + 1 + local active_index = nil + local disabled_item = nil + + -- Add option to disable a subtitle track. This works for all tracks, + -- but why would anyone want to disable audio or video? Better to not + -- let people mistakenly select what is unwanted 99.999% of the time. + -- If I'm mistaken and there is an active need for this, feel free to + -- open an issue. + if track_type == 'sub' then + disabled_item = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true} + items[#items + 1] = disabled_item + end + + for _, track in ipairs(tracklist) do + if track.type == track_type then + local hint_values = {} + local function h(value) hint_values[#hint_values + 1] = value end + + if track.lang then h(track.lang:upper()) end + if track['demux-h'] then + h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p')) + end + if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end + h(track.codec) + if track['audio-channels'] then h(track['audio-channels'] .. ' channels') end + if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end + if track.forced then h('forced') end + if track.default then h('default') end + if track.external then h('external') end + + items[#items + 1] = { + title = (track.title and track.title or 'Track ' .. track.id), + hint = table.concat(hint_values, ', '), + value = track.id, + active = track.selected, + } + + if track.selected then + if disabled_item then disabled_item.active = false end + active_index = #items + end + end + end + + return items, active_index or first_item_index + end + + local function selection_handler(value) + if value == '{load}' then + mp.command(load_command) + else + mp.commandv('set', track_prop, value and value or 'no') + + -- If subtitle track was selected, assume user also wants to see it + if value and track_type == 'sub' then + mp.commandv('set', 'sub-visibility', 'yes') + end + end + end + + return create_self_updating_menu_opener({ + title = menu_title, + type = track_type, + list_prop = 'track-list', + serializer = serialize_tracklist, + on_select = selection_handler, + }) +end + +---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()} + +-- Opens a file navigation menu with items inside `directory_path`. +---@param directory_path string +---@param handle_select fun(path: string): nil +---@param opts NavigationMenuOptions +function open_file_navigation_menu(directory_path, handle_select, opts) + directory = serialize_path(normalize_path(directory_path)) + opts = opts or {} + + if not directory then + msg.error('Couldn\'t serialize path "' .. directory_path .. '.') + return + end + + local files, directories = read_directory(directory.path, opts.allowed_types) + local is_root = not directory.dirname + local path_separator = path_separator(directory.path) + + if not files or not directories then return end + + sort_filenames(directories) + sort_filenames(files) + + -- Pre-populate items with parent directory selector if not at root + -- Each item value is a serialized path table it points to. + local items = {} + + if is_root then + if state.platform == 'windows' then + items[#items + 1] = {title = '..', hint = 'Drives', value = '{drives}', separator = true} + end + else + items[#items + 1] = {title = '..', hint = 'parent dir', value = directory.dirname, separator = true} + end + + local back_path = items[#items] and items[#items].value + local selected_index = #items + 1 + + for _, dir in ipairs(directories) do + items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator} + end + + for _, file in ipairs(files) do + items[#items + 1] = {title = file, value = join_path(directory.path, file)} + end + + for index, item in ipairs(items) do + if not item.value.is_to_parent and opts.active_path == item.value then + item.active = true + if not opts.selected_path then selected_index = index end + end + + if opts.selected_path == item.value then selected_index = index end + end + + ---@type MenuCallback + local function open_path(path, meta) + local is_drives = path == '{drives}' + local is_to_parent = is_drives or #path < #directory_path + local inheritable_options = { + type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path, + } + + if is_drives then + open_drives_menu(function(drive_path) + open_file_navigation_menu(drive_path, handle_select, inheritable_options) + end, { + type = inheritable_options.type, title = inheritable_options.title, selected_path = directory.path, + on_open = opts.on_open, on_close = opts.on_close, + }) + return + end + + local info, error = utils.file_info(path) + + if not info then + msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or '')) + return + end + + if info.is_dir and not meta.modifiers.ctrl then + -- Preselect directory we are coming from + if is_to_parent then + inheritable_options.selected_path = directory.path + end + + open_file_navigation_menu(path, handle_select, inheritable_options) + else + handle_select(path) + end + end + + local function handle_back() + if back_path then open_path(back_path, {modifiers = {}}) end + end + + local menu_data = { + type = opts.type, title = opts.title or directory.basename .. path_separator, items = items, + selected_index = selected_index, + } + local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back} + + return Menu:open(menu_data, open_path, menu_options) +end + +-- Opens a file navigation menu with Windows drives as items. +---@param handle_select fun(path: string): nil +---@param opts? NavigationMenuOptions +function open_drives_menu(handle_select, opts) + opts = opts or {} + local process = mp.command_native({ + name = 'subprocess', + capture_stdout = true, + playback_only = false, + args = {'wmic', 'logicaldisk', 'get', 'name', '/value'}, + }) + local items, selected_index = {}, 1 + + if process.status == 0 then + for _, value in ipairs(split(process.stdout, '\n')) do + local drive = string.match(value, 'Name=([A-Z]:)') + if drive then + local drive_path = normalize_path(drive) + items[#items + 1] = { + title = drive, hint = 'drive', value = drive_path, active = opts.active_path == drive_path, + } + if opts.selected_path == drive_path then selected_index = #items end + end + end + else + msg.error(process.stderr) + end + + return Menu:open( + {type = opts.type, title = opts.title or 'Drives', items = items, selected_index = selected_index}, + handle_select + ) +end diff --git a/multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua b/multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua new file mode 100644 index 0000000..1261666 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/lib/std.lua @@ -0,0 +1,181 @@ +--[[ Stateless utilities missing in lua standard library ]] + +---@param number number +function round(number) return math.floor(number + 0.5) end + +---@param min number +---@param value number +---@param max number +function clamp(min, value, max) return math.max(min, math.min(value, max)) end + +---@param rgba string `rrggbb` or `rrggbbaa` hex string. +function serialize_rgba(rgba) + local a = rgba:sub(7, 8) + return { + color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2), + opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1), + } +end + +-- Trim any `char` from the end of the string. +---@param str string +---@param char string +---@return string +function trim_end(str, char) + local char, end_i = char:byte(), 0 + for i = #str, 1, -1 do + if str:byte(i) ~= char then + end_i = i + break + end + end + return str:sub(1, end_i) +end + +---@param str string +---@param pattern string +---@return string[] +function split(str, pattern) + local list = {} + local full_pattern = '(.-)' .. pattern + local last_end = 1 + local start_index, end_index, capture = str:find(full_pattern, 1) + while start_index do + list[#list + 1] = capture + last_end = end_index + 1 + start_index, end_index, capture = str:find(full_pattern, last_end) + end + if last_end <= (#str + 1) then + capture = str:sub(last_end) + list[#list + 1] = capture + end + return list +end + +-- Get index of the last appearance of `sub` in `str`. +---@param str string +---@param sub string +---@return integer|nil +function string_last_index_of(str, sub) + local sub_length = #sub + for i = #str, 1, -1 do + for j = 1, sub_length do + if str:byte(i + j - 1) ~= sub:byte(j) then break end + if j == sub_length then return i end + end + end +end + +---@param itable table +---@param value any +---@return integer|nil +function itable_index_of(itable, value) + for index, item in ipairs(itable) do + if item == value then return index end + end +end + +---@param itable table +---@param compare fun(value: any, index: number) +---@param from_end? boolean Search from the end of the table. +---@return number|nil index +---@return any|nil value +function itable_find(itable, compare, from_end) + local from, to, step = from_end and #itable or 1, from_end and 1 or #itable, from_end and -1 or 1 + for index = from, to, step do + if compare(itable[index], index) then return index, itable[index] end + end +end + +---@param itable table +---@param decider fun(value: any, index: number) +function itable_filter(itable, decider) + local filtered = {} + for index, value in ipairs(itable) do + if decider(value, index) then filtered[#filtered + 1] = value end + end + return filtered +end + +---@param itable table +---@param value any +function itable_remove(itable, value) + return itable_filter(itable, function(item) return item ~= value end) +end + +---@param itable table +---@param start_pos? integer +---@param end_pos? integer +function itable_slice(itable, start_pos, end_pos) + start_pos = start_pos and start_pos or 1 + end_pos = end_pos and end_pos or #itable + + if end_pos < 0 then end_pos = #itable + end_pos + 1 end + if start_pos < 0 then start_pos = #itable + start_pos + 1 end + + local new_table = {} + for index, value in ipairs(itable) do + if index >= start_pos and index <= end_pos then + new_table[#new_table + 1] = value + end + end + return new_table +end + +---@generic T +---@param a T[]|nil +---@param b T[]|nil +---@return T[] +function itable_join(a, b) + local result = {} + if a then for _, value in ipairs(a) do result[#result + 1] = value end end + if b then for _, value in ipairs(b) do result[#result + 1] = value end end + return result +end + +---@param target any[] +---@param source any[] +function itable_append(target, source) + for _, value in ipairs(source) do target[#target + 1] = value end + return target +end + +---@param target any[] +---@param source any[] +---@param props? string[] +function table_assign(target, source, props) + if props then + for _, name in ipairs(props) do target[name] = source[name] end + else + for prop, value in pairs(source) do target[prop] = value end + end + return target +end + +---@generic T +---@param table T +---@return T +function table_shallow_copy(table) + local result = {} + for key, value in pairs(table) do result[key] = value end + return result +end + +--[[ EASING FUNCTIONS ]] + +function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end +function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end + +--[[ CLASSES ]] + +---@class Class +Class = {} +function Class:new(...) + local object = setmetatable({}, {__index = self}) + object:init(...) + return object +end +function Class:init() end +function Class:destroy() end + +function class(parent) return setmetatable({}, {__index = parent or Class}) end diff --git a/multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua b/multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua new file mode 100644 index 0000000..d573b81 --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/lib/text.lua @@ -0,0 +1,461 @@ +-- https://en.wikipedia.org/wiki/Unicode_block +---@alias CodePointRange {[1]: integer; [2]: integer} + +---@type CodePointRange[] +local zero_width_blocks = { + {0x0000, 0x001F}, -- C0 + {0x007F, 0x009F}, -- Delete + C1 + {0x034F, 0x034F}, -- combining grapheme joiner + {0x061C, 0x061C}, -- Arabic Letter Strong + {0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark} + {0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override} + {0x2060, 0x2060}, -- word joiner + {0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate} + {0xFEFF, 0xFEFF}, -- zero-width non-breaking space + -- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character + {0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited + {0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited + {0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited + {0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited + {0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters) + -- Egyptian Hieroglyph Format Controls and Shorthand format Controls + {0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs + {0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common + -- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters + {0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters) +} + +-- All characters have the same width as the first one +---@type CodePointRange[] +local same_width_blocks = { + {0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han + {0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han + {0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han + {0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han + {0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han + {0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han + {0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han + {0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han + {0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han + {0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han +} + +local width_length_ratio = 0.5 + +---@type integer, integer +local osd_width, osd_height = 100, 100 + +---Get byte count of utf-8 character at index i in str +---@param str string +---@param i integer? +---@return integer +local function utf8_char_bytes(str, i) + local char_byte = str:byte(i) + if char_byte < 0xC0 then return 1 + elseif char_byte < 0xE0 then return 2 + elseif char_byte < 0xF0 then return 3 + elseif char_byte < 0xF8 then return 4 + else return 1 end +end + +---Creates an iterator for an utf-8 encoded string +---Iterates over utf-8 characters instead of bytes +---@param str string +---@return fun(): integer?, string? +local function utf8_iter(str) + local byte_start = 1 + return function() + local start = byte_start + if #str < start then return nil end + local byte_count = utf8_char_bytes(str, start) + byte_start = start + byte_count + return start, str:sub(start, start + byte_count - 1) + end +end + +---Extract Unicode code point from utf-8 character at index i in str +---@param str string +---@param i integer +---@return integer +local function utf8_to_unicode(str, i) + local byte_count = utf8_char_bytes(str, i) + local char_byte = str:byte(i) + local unicode = char_byte + if byte_count ~= 1 then + local shift = 2 ^ (8 - byte_count) + char_byte = char_byte - math.floor(0xFF / shift) * shift + unicode = char_byte * (2 ^ 6) ^ (byte_count - 1) + end + for j = 2, byte_count do + char_byte = str:byte(i + j - 1) - 0x80 + unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j) + end + return round(unicode) +end + +---Convert Unicode code point to utf-8 string +---@param unicode integer +---@return string? +local function unicode_to_utf8(unicode) + if unicode < 0x80 then return string.char(unicode) + else + local byte_count + if unicode < 0x800 then byte_count = 2 + elseif unicode < 0x10000 then byte_count = 3 + elseif unicode < 0x110000 then byte_count = 4 + else return end -- too big + + local res = {} + local shift = 2 ^ 6 + local after_shift = unicode + for _ = byte_count, 2, -1 do + local before_shift = after_shift + after_shift = math.floor(before_shift / shift) + table.insert(res, 1, before_shift - after_shift * shift + 0x80) + end + shift = 2 ^ (8 - byte_count) + table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift) + ---@diagnostic disable-next-line: deprecated + return string.char(unpack(res)) + end +end + +---Update osd resolution if valid +---@param width integer +---@param height integer +local function update_osd_resolution(width, height) + if width > 0 and height > 0 then osd_width, osd_height = width, height end +end + +mp.observe_property('osd-dimensions', 'native', function (_, dim) + if dim then update_osd_resolution(dim.w, dim.h) end +end) + +local measure_bounds +do + local text_osd = mp.create_osd_overlay("ass-events") + text_osd.compute_bounds, text_osd.hidden = true, true + + ---@param ass_text string + ---@return integer, integer, integer, integer + measure_bounds = function(ass_text) + update_osd_resolution(mp.get_osd_size()) + text_osd.res_x, text_osd.res_y = osd_width, osd_height + text_osd.data = ass_text + local res = text_osd:update() + return res.x0, res.y0, res.x1, res.y1 + end +end + +local normalized_text_width +do + ---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number} + local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0} + + ---Measure text width and normalize to a font size of 1 + ---text has to be ass safe + ---@param text string + ---@param size number + ---@param bold boolean + ---@param italic boolean + ---@param horizontal boolean + ---@return number, integer + normalized_text_width = function(text, size, bold, italic, horizontal) + bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90 + local x1, y1 = nil, nil + size = size / 0.8 + -- prevent endless loop + local repetitions_left = 5 + repeat + size = size * 0.8 + bounds_opts.size = size + local ass = assdraw.ass_new() + ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts) + _, _, x1, y1 = measure_bounds(ass.text) + repetitions_left = repetitions_left - 1 + -- make sure nothing got clipped + until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0 + local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1) + return width / size, horizontal and osd_width or osd_height + end +end + +---Estimates character length based on utf8 byte count +---1 character length is roughly the size of a latin character +---@param char string +---@return number +local function char_length(char) + return #char > 2 and 2 or 1 +end + +---Estimates string length based on utf8 byte count +---Note: Making a string in the iterator with the character is a waste here, +---but as this function is only used when measuring whole string widths it's fine +---@param text string +---@return number +local function text_length(text) + if not text or text == '' then return 0 end + local text_length = 0 + for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end + return text_length +end + +---Finds the best orientation of text on screen and returns the estimated max size +---and if the text should be drawn horizontally +---@param text string +---@return number, boolean +local function fit_on_screen(text) + local estimated_width = text_length(text) * width_length_ratio + if osd_width >= osd_height then + -- Fill the screen as much as we can, bigger is more accurate. + return math.min(osd_width / estimated_width, osd_height), true + else + return math.min(osd_height / estimated_width, osd_width), false + end +end + +---Gets next stage from cache +---@param cache {[any]: table} +---@param value any +local function get_cache_stage(cache, value) + local stage = cache[value] + if not stage then + stage = {} + cache[value] = stage + end + return stage +end + +---Is measured resolution sufficient +---@param px integer +---@return boolean +local function no_remeasure_required(px) + return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height) +end + +local character_width +do + ---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}} + local char_width_cache = {} + + ---Get measured width of character + ---@param char string + ---@param bold boolean + ---@return number, integer + character_width = function(char, bold) + ---@type {[string]: {[1]: number, [2]: integer}} + local char_widths = get_cache_stage(char_width_cache, bold) + local width_px = char_widths[char] + if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end + + local unicode = utf8_to_unicode(char, 1) + for _, block in ipairs(zero_width_blocks) do + if unicode >= block[1] and unicode <= block[2] then + char_widths[char] = {0, INFINITY} + return 0, INFINITY + end + end + + local measured_char = nil + for _, block in ipairs(same_width_blocks) do + if unicode >= block[1] and unicode <= block[2] then + measured_char = unicode_to_utf8(block[1]) + width_px = char_widths[measured_char] + if width_px and no_remeasure_required(width_px[2]) then + char_widths[char] = width_px + return width_px[1], width_px[2] + end + break + end + end + + if not measured_char then measured_char = char end + -- half as many repetitions for wide characters + local char_count = 10 / char_length(char) + local max_size, horizontal = fit_on_screen(measured_char:rep(char_count)) + local size = math.min(max_size * 0.9, 50) + char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100) + local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count + if measured_char == enclosing_char then enclosing_char = '' + else enclosing_width = 2 * character_width(enclosing_char, bold) end + local width_ratio, width, px = nil, nil, nil + repeat + char_count = next_char_count + local str = enclosing_char .. measured_char:rep(char_count) .. enclosing_char + width, px = normalized_text_width(str, size, bold, false, horizontal) + width = width - enclosing_width + width_ratio = width * size / (horizontal and osd_width or osd_height) + next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100) + until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count + width = width / char_count + + width_px = {width, px} + if char ~= measured_char then char_widths[measured_char] = width_px end + char_widths[char] = width_px + return width, px + end +end + +---Calculate text width from individual measured characters +---@param text string|number +---@param bold boolean +---@return number, integer +local function character_based_width(text, bold) + local max_width = 0 + local min_px = INFINITY + for line in tostring(text):gmatch("([^\n]*)\n?") do + local total_width = 0 + for _, char in utf8_iter(line) do + local width, px = character_width(char, bold) + total_width = total_width + width + if px < min_px then min_px = px end + end + if total_width > max_width then max_width = total_width end + end + return max_width, min_px +end + +---Measure width of whole text +---@param text string|number +---@param bold boolean +---@param italic boolean +---@return number, integer +local function whole_text_width(text, bold, italic) + text = tostring(text) + local size, horizontal = fit_on_screen(text) + return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal) +end + +---Scale normalized width to real width based on font size and italic +---@param opts {size: number; italic?: boolean} +---@return number, number +local function opts_factor_offset(opts) + return opts.size, opts.italic and opts.size * 0.2 or 0 +end + +---Scale normalized width to real width based on font size and italic +---@param opts {size: number; italic?: boolean} +---@return number +local function normalized_to_real(width, opts) + local factor, offset = opts_factor_offset(opts) + return factor * width + offset +end + +do + ---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}} + local width_cache = {} + + ---Calculate width of text with the given opts + ---@param text string|number + ---@return number + ---@param opts {size: number; bold?: boolean; italic?: boolean} + function text_width(text, opts) + if not text or text == '' then return 0 end + + ---@type boolean, boolean + local bold, italic = opts.bold or options.font_bold, opts.italic or false + + if options.text_width_estimation then + ---@type {[string|number]: {[1]: number, [2]: integer}} + local text_width = get_cache_stage(width_cache, bold) + local width_px = text_width[text] + if width_px and no_remeasure_required(width_px[2]) then return normalized_to_real(width_px[1], opts) end + + local width, px = character_based_width(text, bold) + width_cache[bold][text] = {width, px} + return normalized_to_real(width, opts) + else + ---@type {[string|number]: {[1]: number, [2]: integer}} + local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic) + local width_px = text_width[text] + if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end + + local width, px = whole_text_width(text, bold, italic) + width_cache[bold][italic][text] = {width, px} + return width * opts.size + end + end +end + +do + ---@type {[string]: string} + local cache = {} + + ---Get width of formatted timestamp as if all the digits were replaced with 0 + ---@param timestamp string + ---@param opts {size: number; bold?: boolean; italic?: boolean} + ---@return number + function timestamp_width(timestamp, opts) + local substitute = cache[#timestamp] + if not substitute then + substitute = timestamp:gsub('%d', '0') + cache[#timestamp] = substitute + end + return text_width(substitute, opts) + end +end + +---Wrap the text at the closest opportunity to target_line_length +---@param text string +---@param opts {size: number; bold?: boolean; italic?: boolean} +---@param target_line_length number +---@return string +function wrap_text(text, opts, target_line_length) + local target_line_width = target_line_length * width_length_ratio * opts.size + local bold, scale_factor, scale_offset = opts.bold or false, opts_factor_offset(opts) + local wrap_at_chars = {' ', ' ', '-', '–'} + local remove_when_wrap = {' ', ' '} + local lines = {} + for text_line in text:gmatch("([^\n]*)\n?") do + local line_width = scale_offset + local line_start = 1 + local before_end = nil + local before_width = scale_offset + local before_line_start = 0 + local before_removed_width = 0 + for char_start, char in utf8_iter(text_line) do + local char_end = char_start + #char - 1 + local can_wrap = false + for _, c in ipairs(wrap_at_chars) do + if char == c then + can_wrap = true + break + end + end + local char_width = character_width(char, bold) * scale_factor + line_width = line_width + char_width + if can_wrap or (char_end == #text_line) then + local remove = false + for _, c in ipairs(remove_when_wrap) do + if char == c then + remove = true + break + end + end + local line_width_after_remove = line_width - (remove and char_width or 0) + if line_width_after_remove < target_line_width then + before_end = remove and char_start - 1 or char_end + before_width = line_width_after_remove + before_line_start = char_end + 1 + before_removed_width = remove and char_width or 0 + else + if (target_line_width - before_width) < + (line_width_after_remove - target_line_width) then + lines[#lines + 1] = text_line:sub(line_start, before_end) + line_start = before_line_start + line_width = line_width - before_width - before_removed_width + scale_offset + else + lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end) + line_start = char_end + 1 + line_width = scale_offset + end + before_end = line_start + before_width = scale_offset + end + end + end + if #text_line >= line_start then lines[#lines + 1] = text_line:sub(line_start) + elseif text_line == '' then lines[#lines + 1] = '' end + end + return table.concat(lines, '\n') +end diff --git a/multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua b/multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua new file mode 100644 index 0000000..f64485c --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/lib/utils.lua @@ -0,0 +1,609 @@ +--[[ UI specific utilities that might or might not depend on its state or options ]] + +-- Sorting comparator close to (but not exactly) how file explorers sort files. +sort_filenames = (function() + local symbol_order + local default_order + + if state.platform == 'windows' then + symbol_order = { + ['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7, + ['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14, + ['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20, + } + default_order = 21 + else + symbol_order = { + ['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8, + ['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14, + ['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23, + } + default_order = 21 + end + + -- Alphanumeric sorting for humans in Lua + -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua + local function pad_number(n, d) + return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d)) + or ("%03d%s"):format(#n, n) + end + + --- In place sorting of filenames + ---@param filenames string[] + return function(filenames) + local tuples = {} + for i, filename in ipairs(filenames) do + local first_char = filename:sub(1, 1) + local order = symbol_order[first_char] or default_order + local formatted = filename:lower():gsub('0*(%d+)%.?(%d*)', pad_number) + tuples[i] = {order, formatted, filename} + end + table.sort(tuples, function(a, b) + if a[1] ~= b[1] then return a[1] < b[1] end + return a[2] == b[2] and #b[3] < #a[3] or a[2] < b[2] + end) + for i, tuple in ipairs(tuples) do filenames[i] = tuple[3] end + end +end)() + +-- Creates in-between frames to animate value from `from` to `to` numbers. +---@param from number +---@param to number|fun():number +---@param setter fun(value: number) +---@param factor_or_callback? number|fun() +---@param callback? fun() Called either on animation end, or when animation is killed. +function tween(from, to, setter, factor_or_callback, callback) + local factor = factor_or_callback + if type(factor_or_callback) == 'function' then callback = factor_or_callback end + if type(factor) ~= 'number' then factor = 0.3 end + + local current, done, timeout = from, false, nil + local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end + local cutoff = math.abs(get_to() - from) * 0.01 + + local function finish() + if not done then + done = true + timeout:kill() + if callback then callback() end + end + end + + local function tick() + local to = get_to() + current = current + ((to - current) * factor) + local is_end = math.abs(to - current) <= cutoff + setter(is_end and to or current) + request_render() + if is_end then finish() + else timeout:resume() end + end + + timeout = mp.add_timeout(state.render_delay, tick) + tick() + + return finish +end + +---@param point {x: number; y: number} +---@param rect {ax: number; ay: number; bx: number; by: number} +function get_point_to_rectangle_proximity(point, rect) + local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx) + local dy = math.max(rect.ay - point.y, 0, point.y - rect.by) + return math.sqrt(dx * dx + dy * dy) +end + +---@param point_a {x: number; y: number} +---@param point_b {x: number; y: number} +function get_point_to_point_proximity(point_a, point_b) + local dx, dy = point_a.x - point_b.x, point_a.y - point_b.y + return math.sqrt(dx * dx + dy * dy) +end + +-- Call function with args if it exists +function call_maybe(fn, ...) + if type(fn) == 'function' then fn(...) end +end + +-- Extracts the properties used by property expansion of that string. +---@param str string +---@param res { [string] : boolean } | nil +---@return { [string] : boolean } +function get_expansion_props(str, res) + res = res or {} + for str in str:gmatch('%$(%b{})') do + local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$') + if name then + local s = name:find('==') or nil + if s then name = name:sub(0, s - 1) end + res[name] = true + if str and str ~= '' then get_expansion_props(str, res) end + end + end + return res +end + +-- Escape a string for verbatim display on the OSD. +---@param str string +function ass_escape(str) + -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if + -- it isn't followed by a recognized character, so add a zero-width + -- non-breaking space + str = str:gsub('\\', '\\\239\187\191') + str = str:gsub('{', '\\{') + str = str:gsub('}', '\\}') + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub('\n', '\239\187\191\\N') + -- Turn leading spaces into hard spaces to prevent ASS from stripping them + str = str:gsub('\\N ', '\\N\\h') + str = str:gsub('^ ', '\\h') + return str +end + +---@param seconds number +---@param max_seconds number|nil Trims unnecessary `00:` if time is not expected to reach it. +---@return string +function format_time(seconds, max_seconds) + local human = mp.format_time(seconds) + if options.time_precision > 0 then + local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1) + human = human .. '.' .. string.sub(formatted, 3) + end + if max_seconds then + local trim_length = (max_seconds < 60 and 7 or (max_seconds < 3600 and 4 or 0)) + if trim_length > 0 then + local has_minus = seconds < 0 + human = string.sub(human, trim_length + (has_minus and 1 or 0)) + if has_minus then human = '-' .. human end + end + end + return human +end + +---@param opacity number 0-1 +function opacity_to_alpha(opacity) + return 255 - math.ceil(255 * opacity) +end + +path_separator = (function() + local os_separator = state.platform == 'windows' and '\\' or '/' + + -- Get appropriate path separator for the given path. + ---@param path string + ---@return string + return function(path) + return path:sub(1, 2) == '\\\\' and '\\' or os_separator + end +end)() + +-- Joins paths with the OS aware path separator or UNC separator. +---@param p1 string +---@param p2 string +---@return string +function join_path(p1, p2) + local p1, separator = trim_trailing_separator(p1) + -- Prevents joining drive letters with a redundant separator (`C:\\foo`), + -- as `trim_trailing_separator()` doesn't trim separators from drive letters. + return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator.. p2 +end + +-- Check if path is absolute. +---@param path string +---@return boolean +function is_absolute(path) + if path:sub(1, 2) == '\\\\' then return true + elseif state.platform == 'windows' then return path:find('^%a+:') ~= nil + else return path:sub(1, 1) == '/' end +end + +-- Ensure path is absolute. +---@param path string +---@return string +function ensure_absolute(path) + if is_absolute(path) then return path end + return join_path(state.cwd, path) +end + +-- Remove trailing slashes/backslashes. +---@param path string +---@return string path, string trimmed_separator_type +function trim_trailing_separator(path) + local separator = path_separator(path) + path = trim_end(path, separator) + if state.platform == 'windows' then + -- Drive letters on windows need trailing backslash + if path:sub(#path) == ':' then path = path .. '\\' end + else + if path == '' then path = '/' end + end + return path, separator +end + +-- Ensures path is absolute, remove trailing slashes/backslashes. +-- Lightweight version of normalize_path for performance critical parts. +---@param path string +---@return string +function normalize_path_lite(path) + if not path or is_protocol(path) then return path end + path = trim_trailing_separator(ensure_absolute(path)) + return path +end + +-- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication. +---@param path string +---@return string +function normalize_path(path) + if not path or is_protocol(path) then return path end + + path = ensure_absolute(path) + local is_unc = path:sub(1, 2) == '\\\\' + if state.platform == 'windows' or is_unc then path = path:gsub('/', '\\') end + path = trim_trailing_separator(path) + + --Deduplication of path separators + if is_unc then path = path:gsub('(.\\)\\+', '%1') + elseif state.platform == 'windows' then path = path:gsub('\\\\+', '\\') + else path = path:gsub('//+', '/') end + + return path +end + +-- Check if path is a protocol, such as `http://...`. +---@param path string +function is_protocol(path) + return type(path) == 'string' and (path:find('^%a[%a%d-_]+://') ~= nil or path:find('^%a[%a%d-_]+:\\?') ~= nil) +end + +---@param path string +---@param extensions string[] Lowercase extensions without the dot. +function has_any_extension(path, extensions) + local path_last_dot_index = string_last_index_of(path, '.') + if not path_last_dot_index then return false end + local path_extension = path:sub(path_last_dot_index + 1):lower() + for _, extension in ipairs(extensions) do + if path_extension == extension then return true end + end + return false +end + +---@return string +function get_default_directory() + return mp.command_native({'expand-path', options.default_directory}) +end + +-- Serializes path into its semantic parts. +---@param path string +---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;} +function serialize_path(path) + if not path or is_protocol(path) then return end + + local normal_path = normalize_path_lite(path) + local dirname, basename = utils.split_path(normal_path) + if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end + local dot_i = string_last_index_of(basename, '.') + + return { + path = normal_path, + is_root = dirname == nil, + dirname = dirname, + basename = basename, + filename = dot_i and basename:sub(1, dot_i - 1) or basename, + extension = dot_i and basename:sub(dot_i + 1) or nil, + } +end + +-- Reads items in directory and splits it into directories and files tables. +---@param path string +---@param allowed_types? string[] Filter `files` table to contain only files with these extensions. +---@return string[]|nil files +---@return string[]|nil directories +function read_directory(path, allowed_types) + local items, error = utils.readdir(path, 'all') + + if not items then + msg.error('Reading files from "' .. path .. '" failed: ' .. error) + return nil, nil + end + + local files, directories = {}, {} + + for _, item in ipairs(items) do + if item ~= '.' and item ~= '..' then + local info = utils.file_info(join_path(path, item)) + if info then + if info.is_file then + if not allowed_types or has_any_extension(item, allowed_types) then + files[#files + 1] = item + end + else directories[#directories + 1] = item end + end + end + end + + return files, directories +end + +-- Returns full absolute paths of files in the same directory as `file_path`, +-- and index of the current file in the table. +-- Returned table will always contain `file_path`, regardless of `allowed_types`. +---@param file_path string +---@param allowed_types? string[] Filter adjacent file types. Does NOT filter out the `file_path`. +function get_adjacent_files(file_path, allowed_types) + local current_meta = serialize_path(file_path) + if not current_meta then return end + local files = read_directory(current_meta.dirname) + if not files then return end + sort_filenames(files) + local current_file_index + local paths = {} + for _, file in ipairs(files) do + local is_current_file = current_meta.basename == file + if is_current_file or not allowed_types or has_any_extension(file, allowed_types) then + paths[#paths + 1] = join_path(current_meta.dirname, file) + if is_current_file then current_file_index = #paths end + end + end + if not current_file_index then return end + return paths, current_file_index +end + +-- Navigates in a list, using delta or, when `state.shuffle` is enabled, +-- randomness to determine the next item. Loops around if `loop-playlist` is enabled. +---@param list table +---@param current_index number +---@param delta number +function decide_navigation_in_list(list, current_index, delta) + if #list < 2 then return #list, list[#list] end + + if state.shuffle then + local new_index = current_index + math.randomseed(os.time()) + while current_index == new_index do new_index = math.random(#list) end + return new_index, list[new_index] + end + + local new_index = current_index + delta + if mp.get_property_native('loop-playlist') then + if new_index > #list then new_index = new_index % #list + elseif new_index < 1 then new_index = #list - new_index end + elseif new_index < 1 or new_index > #list then + return + end + + return new_index, list[new_index] +end + +---@param delta number +function navigate_directory(delta) + if not state.path or is_protocol(state.path) then return false end + local paths, current_index = get_adjacent_files(state.path, config.types.autoload) + if paths and current_index then + local _, path = decide_navigation_in_list(paths, current_index, delta) + if path then mp.commandv('loadfile', path) return true end + end + return false +end + +---@param delta number +function navigate_playlist(delta) + local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1') + if playlist and #playlist > 1 and pos then + local index = decide_navigation_in_list(playlist, pos, delta) + if index then mp.commandv('playlist-play-index', index - 1) return true end + end + return false +end + +---@param delta number +function navigate_item(delta) + if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end +end + +-- Can't use `os.remove()` as it fails on paths with unicode characters. +-- Returns `result, error`, result is table of: +-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean` +---@param path string +function delete_file(path) + if state.platform == 'windows' then + if options.use_trash then + local ps_code = [[ + Add-Type -AssemblyName Microsoft.VisualBasic + [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('__path__', 'OnlyErrorDialogs', 'SendToRecycleBin') + ]] + + local escaped_path = string.gsub(path, "'", "''") + escaped_path = string.gsub(escaped_path, "’", "’’") + escaped_path = string.gsub(escaped_path, "%%", "%%%%") + ps_code = string.gsub(ps_code, "__path__", escaped_path) + args = { 'powershell', '-NoProfile', '-Command', ps_code } + else + args = { 'cmd', '/C', 'del', path } + end + else + if options.use_trash then + --On Linux and Macos the app trash-cli/trash must be installed first. + args = { 'trash', path } + else + args = { 'rm', path } + end + end + return mp.command_native({ + name = 'subprocess', + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) +end + +function serialize_chapter_ranges(normalized_chapters) + local ranges = {} + local simple_ranges = { + {name = 'openings', patterns = { + '^op ', '^op$', ' op$', + '^opening$', ' opening$' + }, requires_next_chapter = true}, + {name = 'intros', patterns = { + '^intro$', ' intro$', + '^avant$', '^prologue$' + }, requires_next_chapter = true}, + {name = 'endings', patterns = { + '^ed ', '^ed$', ' ed$', + '^ending ', '^ending$', ' ending$', + }}, + {name = 'outros', patterns = { + '^outro$', ' outro$', + '^closing$', '^closing ', + '^preview$', '^pv$', + }}, + } + local sponsor_ranges = {} + + -- Extend with alt patterns + for _, meta in ipairs(simple_ranges) do + local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns + if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end + end + + -- Clone chapters + local chapters = {} + for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end + + for i, chapter in ipairs(chapters) do + -- Simple ranges + for _, meta in ipairs(simple_ranges) do + if config.chapter_ranges[meta.name] then + local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end) + if match then + local next_chapter = chapters[i + 1] + if next_chapter or not meta.requires_next_chapter then + ranges[#ranges + 1] = table_assign({ + start = chapter.time, + ['end'] = next_chapter and next_chapter.time or INFINITY, + }, config.chapter_ranges[meta.name]) + end + end + end + end + + -- Sponsor blocks + if config.chapter_ranges.ads then + local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)') + if id then -- ad range from sponsorblock + for j = i + 1, #chapters, 1 do + local end_chapter = chapters[j] + local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)') + if end_match then + local range = table_assign({ + start_chapter = chapter, end_chapter = end_chapter, + start = chapter.time, ['end'] = end_chapter.time, + }, config.chapter_ranges.ads) + ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range + end_chapter.is_end_only = true + break + end + end -- single chapter for ad + elseif not chapter.is_end_only and + (chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then + local next_chapter = chapters[i + 1] + ranges[#ranges + 1] = table_assign({ + start = chapter.time, + ['end'] = next_chapter and next_chapter.time or INFINITY, + }, config.chapter_ranges.ads) + end + end + end + + -- Fix overlapping sponsor block segments + for index, range in ipairs(sponsor_ranges) do + local next_range = sponsor_ranges[index + 1] + if next_range then + local delta = next_range.start - range['end'] + if delta < 0 then + local mid_point = range['end'] + delta / 2 + range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01 + next_range.start, next_range.start_chapter.time = mid_point, mid_point + end + end + end + table.sort(chapters, function(a, b) return a.time < b.time end) + + return chapters, ranges +end + +-- Ensures chapters are in chronological order +function normalize_chapters(chapters) + if not chapters then return {} end + -- Ensure chronological order + table.sort(chapters, function(a, b) return a.time < b.time end) + -- Ensure titles + for index, chapter in ipairs(chapters) do + chapter.title = chapter.title or ('Chapter ' .. index) + chapter.lowercase_title = chapter.title:lower() + end + return chapters +end + +function serialize_chapters(chapters) + chapters = normalize_chapters(chapters) + if not chapters then return end + --- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering + local opts = {size = 1, bold = true} + for index, chapter in ipairs(chapters) do + chapter.index = index + chapter.title_wrapped = wrap_text(chapter.title, opts, 25) + chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts) + chapter.title_wrapped = ass_escape(chapter.title_wrapped) + end + return chapters +end + +--[[ RENDERING ]] + +function render() + if not display.initialized then return end + state.render_last_time = mp.get_time() + + cursor.reset_handlers() + + -- Actual rendering + local ass = assdraw.ass_new() + + for _, element in Elements:ipairs() do + if element.enabled then + local result = element:maybe('render') + if result then + ass:new_event() + ass:merge(result) + end + end + end + + cursor.decide_keybinds() + + -- submit + if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then + return + end + + osd.res_x = display.width + osd.res_y = display.height + osd.data = ass.text + osd.z = 2000 + osd:update() + + update_margins() +end + +-- Request that render() is called. +-- The render is then either executed immediately, or rate-limited if it was +-- called a small time ago. +state.render_timer = mp.add_timeout(0, render) +state.render_timer:kill() +function request_render() + if state.render_timer:is_enabled() then return end + local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time)) + state.render_timer.timeout = timeout + state.render_timer:resume() +end diff --git a/multimedia/.config/mpv/scripts/uosc_shared/main.lua b/multimedia/.config/mpv/scripts/uosc_shared/main.lua new file mode 100644 index 0000000..323027f --- /dev/null +++ b/multimedia/.config/mpv/scripts/uosc_shared/main.lua @@ -0,0 +1,5 @@ +--[[ +File required for compatibility between mpv: +- 0.32 - doesn't support `dir/main.lua`, so we need `uosc.lua` in root +- 0.33 - requires `main.lua` in directories +]]