From b03b670d56df2ac22b8775a2a1ce8812f6621291 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:45:39 +0100 Subject: [PATCH 01/36] [feature-107] add file associations for .bplist (not completed) --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 24abeafbf..147330b0c 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,10 @@ "publish": { "provider": "github", "owner": "Zagrios" - } + }, + "fileAssociations": [ + { "ext": "bplist", "description": "Beat Saber Playlist", "icon": "./assets/favicon.ico", "role": "Viewer" } + ] }, "repository": { "type": "git", From 778b4f6ee67b0f5fa1a72a7df25e73f9f119d1bc Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Sat, 3 Feb 2024 16:03:17 +0100 Subject: [PATCH 02/36] [feature-107] Can now open .bplist file with BSManager --- assets/bsm_file.ico | Bin 0 -> 410598 bytes package.json | 2 +- src/main/main.ts | 40 +++++++++---- .../local-playlists-manager.service.ts | 27 +++++---- src/main/services/file-association.service.ts | 56 ++++++++++++++++++ 5 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 assets/bsm_file.ico create mode 100644 src/main/services/file-association.service.ts diff --git a/assets/bsm_file.ico b/assets/bsm_file.ico new file mode 100644 index 0000000000000000000000000000000000000000..48c48c62790567d23b1d28aebdf28937d2d31d27 GIT binary patch literal 410598 zcmeEv2Y^<^)%KE@Xi&sn>Dy>Z?@C7@il~vqn9pcpqDf4%B>JH!w%C);#2RCviDJPn zSP+q7r?*{p>AfznefK^8^UU12ckX@feajX~eqWfKd*`+}=Q(rc%$b&v(IDf9jAM?; zK-?nZH-}|p{4FCRUr7A^Goi@5KvM`vVo?C6|lXJj;Mo{`bNzdmn0 zFe78m;EaqbuXL_Iis$jdF=L$TM>NQI{PYGH{PBCOTO5`#J@Y#mt#OW5BSohp%IJ^p zhW+^Tm@#*J=ds5gJ8ImxaYv3DkAQeQk5xP#@q-8t9Lak|j=bc%e5?93K#c!Omo00* zd*{xQJ$rWV-m|-C+uowx+xG0;i@0dp9v&kN@vfp>J1dqgUDnIwgKuHT&(H4^6OkDI zNDTj+zerF?Om==N3kn99=k$9TWE2*z=)gOxa8D`X^7YF_-nc_z>%K;cxl*Ctm6w;- zm(OP~?<&r~^MxJMeY-bH?A2RE{?EzIFB+WURvTvF!yCd!V=6 zH{d8~+w?p+t>s73{>&wE`Q;)HJtT7Oxz$oyLMol_TQA}F6|Zh@E4TMPQnHR~FHM@g zCT-j9lqs)?l$46JX%my>E2_bVwfMdtfmIUg5UbcL^4yhjeY*y7W&g9KRrX|Qdq#m= zcU?>_yF4Zrj3|}L3ZiMAR~f*c1{oWOA1*O$o&T{&RViqGQI0?E6UoUHx$??A5+i@{ z%gZn9ufC2y4IKGfzkWUN)6eq@1p4ET3+2Z@{z~rs+ioQ6f)JD_{Fg0T+TY{6&cg=v zJfDn7MP<2Elo4ynO*P_@*p3}r5Q{8cvUvKqapRBFPigFVykWxzM}F1U)pt}T)Kh$q z2=1AopX1jZ{yDE$xw4>r@xJ1cvXau>B_*ZXN=k~il@=GPxOm^*@=cr8zpbA2K8aKB zuhYkC z*RCG5cHL^EA$E>cyn6MWE0!(&o&Rn28IK!eY~Q*yUqS2q(Wv7jaNwT0mtulv_t@mv zZ#U^Xwr~5KuP!eum9kQX5*^CRq^zo1${?R*B_&d>(o3EDP`HtS9b2~*>UU~h>35np z>-_K7vHdf?zqDkZl$I7Flt@W+O!$V<;=PDtQd(XiCB?XI?kOqO@87{RKu=v@Vw$*!F{eEGLOVUBl}$3Bbg-5@f$6Zp|vM)zwf9Z&g=49Gl5+GYJ+nq__{&0CI> zGuqFP0Rv*vtCz@cf6Moa6cw5G+syYL@2r&1WM_fMZ5<_cTMxOUU1J&8ssCZL$YY+KJyCbDv zW~Q{vdRtnwd{H`go+thKiTvUhBKatZBQFu@(W4meu9ouh9iDvZPng%b@y=@GzwlF$ zTiVK=Te{1ItsBXZ%;O{{^EqkOax&hvTDrpo_;e<79FuFW70J!1kVOkCalX^ze^};0 z(asq1zv2Ut>zl~dzjTswGaE_olQJah^xLI*tJkGt$9edM$ovJ6-?EtEQ>#{$^2O(< zcVg!KHgo(R+P$w>$)`+xK=OapLV7mJkb8dAR<64ESvmQ%m!&;$9W+qnCqEIn@IsNS z>}t9GhRuqWy?d3vhd}{afIr9>}omedrwPt_M37pOr7p1 za#>kr^2j3_adn@%XUC3Y_~QLVyVd*4D=JhCs~*IkufN(Xr<^iDjy!TKWV1|q^(>Qj z-(kLKyE*pH?%k_2H~c)YDJXK@Us-{=+yv-O7#6a1OOgEJ9}DDXKU*d9=kG#Vk?h*F zO?GVC26AJvcI}$&`d!gq@!nqno2Fca3cR1k+GgOZsGZ7X{rU~EX3d(|mMvRk&z`-I zsZV|Ni=A(CT(qZ1={NV?h6d>UigNwESQYeUd8usMwl!7?J?c49#x^>&Aty{MuY)05JYV+pJUf8tpmW>+zU&SPO*y2*wT@!v=*F`0y@xwoAh1ac)N3MVsnk>+jk&rL)eP@ z7`CZrN@!0lSTJvt{hg>|et+?jCBM`~0qq2JV9tD{C2-;v9+axU{dh_KiWT>`-*lKj z```Sm!;Ga%7yq0uXPZaada%Q^P3Xk5|Cnakf$n|gUUj`#=_9^t*|H^L%(s%o&@1}; z&~c@(Y0>u4-&1xek6{~D!5+nXOUuf1KFmETrVXp}NPC&=Shj4L{yqsc^qYe~hRpY= zYmJ>PN?{|H;{7f^6mRzFJR`s6`zzG8P=GVr;C^zPY(@BT}Kw)$UkVB>XxPXu|my}fL?v9s*_=5;BlL`b* zoZz0E*khlQqZ*Bcow8IG zF4~DNvz>wZEcku9@C7)qV#U&*A)_rb{f&XRe* z>nHMKcMK&0^O}zO_XlkmdenfB4?c)lMy3C1`ozDipkKSJ7nM9BElb! zKgjic+d4DfmoHxtxv8zl{b$SUU!5uaS{#Y*H}?<_?iE}PmAQ0`T73ugZ`-BO6U7BXQIe;%^~}pWx^#n(z9tJ>3>>=bU&%Bw9I^0 zGF!dq$h@G;iG2B`$f{MM+F8AN#o({p2isoLUy!f)?@`LH<4h&^Q&&3W4^@j)FM1z3 zXOKMHv!S&AL5AFYUMuWLV?`(;eSEDnc z@1foKoHT7ZUV8Ulr@r3-9^dnRxCvO3=eiwMq4a;u!9Vot-iMtd{e936fii8y%IZqh z2BJ+sdfxwFjr8mL4)pyq(!BY6@LS|pzg~;_3O)_+uDS~CCp;4a4g9TMU3jPYP%sz4 z^8zFP_NrodfUtG3(oG1LJ$dg8c#H0b|IQZs+TG+t#h{ z9iVRkzcs5@t8W{+bnN6IBrB$Tf^R5);6LQZ@Za#j!$9YS-&XB^w#$(xLpS zCqF~JfZZ>df94_l7remCdUtNzxG{w8_)mwifOqBR=UrA+RVDlO?J3>6cW?3Dy?YQC z7Zn#FpbxRQX!q`7_*{y2Gj_tBJw>s?!h%gxro7f7?A>8$CRg^gF=NIYcH@mV9*MZ& z)mLBL@ak(27zcx$j$_<^6yImR%e>!<6JOQ$@gDzu{xk1w-u))czSBN7*Ub~r*Zg?^twp+QCq{6*h0`n=ix4DT%K}-H`OD&~NF)6rW9cn|*_Q zoZ!>;B|Or$ZQJjkeDcXho_5-44Z}jlX&DXiNa~@1xwnD!jD2jbXB^q01qN6o$aaED zE)`#V@x_Dt_U>VwS+aT4#$pq;nB$Ebi+Q}s4I7F#ZA>j}#C`BP7H`~uu-=Jzj5PG! z$Tweo@xGf89;A}tz&-F8J)tzPUllt_Su1B(MY{){&Nk25DfVslv3(DZ3-a?n_vJru z63A-csye9$;IRGPv?$@@nA{$iGcXV)+T? zGWvX}%0I>_ss4R`{uM2dr4srdRX%ZCQKdX9>Ky6x(Uq#}0s2Dda`O+mSgz4FSNwP7 zCpw#vSC+Z_x5g#}a8PtqV$2b~Sw@@wvzIYGX{f{=9xml8ztsL*_#4ZRXZj9p4E4AM z@Wt_M8lEWr=hv+K+Qhi|kK%uY$v4^opCjK7ou~Yuatj1)<%rBJX2Qgf`b+Ojye1u)wikiZP8fzHa}d3XE%`EIX{=vTTYYHo4yJk z?PU07CxeCw(y-yb;Vb$`?z(d&-d=)swbo^!@`Eg;m~EFL^zSJBPo&GXtOmc4Z}t@^ z`PO-l-EZWivyAH7M9xOO`!sJX=Vu=VzhWclls!uM(lfJOS9zy@SNoU8VtmF|@H2^= zdFDo0yM{g$EvGn0QGRTS>|?`!FaMQGK34if?&#&^|G~bp_J*!<^V!HZ{NQIH-@~$x zm5Xv4$a%R zd2^YZ;K1g8<-B)w{>M1^hyR%V`~EE(!-smb49z-DhUXrGyl2RuoGy}$@lq|aKG6AQ z-Vx|q&W4Zw?6Y^MywkToXpjJh5}7cuAdvt3PZDG;$-lIt)IY0M!mG=vCzawYmTmV1(yU6>yuDocO9}B0_89H~amb>rD zNB%iR&(S}1z<)dcRi8Yk`wggX?neIaIa{vjc8cQv`MJl+g}H~JoL;JAy;atm;PYg~ z^UTbbRA0)OXUdcJh(C76wmy*yf%U+PZud6tasDL-m^0>;BJ z|38p(a@!*BuPY36AJ_{@-r4VG%4uhGm%cn1FLQpQ@yf1UV+w~Zop;LIIqV;(P*)ZI z1NpJDn#!@sKi^nA;X2r4?IAzNKl)8~jp~kadz_q`bu8rPDD>N&C^=cV@~`82@73wNP91dT9r?JR z%Wq);@*nuQ`Nx5K9Jt3m)%l0NngaRgrK0+cnSbd1g6lfVV8{+>I3Ifd!rbpk=j^|z zK15Ydp%0h+xdWjuDL*W)|NN(wf4uXe3pFmxf46QT*ZwXKH|X|zSpMz2g&x~P;n>Ij zM*#lytx)&&hVJhti+|rq=}y+Sl%w;qzat%*U4r~iQ~9ReY}M)oRacz@y~A;w*IeVd z#Q*(oPj*OO>oyhg;DalW|8hlSjq>jjr3uNi%5UXHy!)RcMQ+GM`Rys6U(*4$Wn(2j zXPth83_B%5F7Mda(m~WelGj`CxRW`^ny9tQ@UW$GoTOWWfPle3S{kXw44Ae$zQl1=lMVO;JM^np%B z{iW@N&YkD$G7KKGzG69`ERi1ay$!W({{5w{@7AHO?^}8=AG@Yr2Mxtnc#S( zlYjP?ss2IAkrSwss`2FtjA3P8Sv6?n?>E1hCExr0^C-KMm7Q;N5apbCr=IE3WtF`D z{z}{p9>l;8w7tsF=coKMkRSdG{}W^{>7|t`$$$3uv9Ht1yGsM{U^?>8{zu+BW5#-n zL4QRWH5xC+9yeZ^G?@S$v{RLVGcoqzoO70=-+Ke@tyX=cD&J^dmoqpt==>Lc!VIP& zCsBkHu3V|*r?Lw9WS?ya4vGfdUsFD!y@^AW@P3+M@{;L2C6b?9NSv0>@ zt9Ho!_brwG`$s;KbltRT=T6vXE2VH%p{%k)VWAWjti(9&68Ykb&*%B_nE;5;OGtF{ zkFsj`Z~Bgn44K%-kB@{j%+ z!FX^rCScpPZR&W-=FPEv`}VP)9%cKzkqNUK%JBFny!=D{ng1XSI5#w?{42hD<=msm z(8ByzApe_?XUs*wmkg+8DQSy@*lv!D#t3{&`I(6 z->mWue#bZ_fxMqOb!v)vPbKG?WTQW;q1#j);LE>54`s#)p1e3|A^a!(91p|1Q%8~a zAAR&uVtMx=fc&RO0(66_zsRHb{G0l~gK5QxEIuMLVbL<4&Ryd zRk$JPJ$1~oZ(mnq4se`Se}pjzW}H?A)*?CJznaKU*fU{iia76rcXYt1P9p<#mFwER z$2@Q1WF&;oAWZgDqU*jd`%e5%CAfqe6JPbeDalz@#^JbwSO#1;I+vh&pR9}d^?7P=G ziN%CG0O6E_@SiFUsh0`+&SguNe8R8M52(i^d1J3qJ5P12Q^b2QMknq&RvPn0xd7qy zg8x#N{*ZjQb8N$|IcZj2-LZCIjiW0Cnv=<$M}KnsO>LD9Hw^vucuUejEH$3tbSA{w$IAq=lfr#eDD@@( z#+=qv4Zeuqv|9vKINrj2U>1n7tV zh`lf-l;S*27oG0X^Za4B{L-U#N~Qym`isbJAz?%zwCU9bULM>T-qW- z3ZH*i&KdEAaIDC$uKEOX(^sSKm|sB!ALBY#7T@uGRo^=Jr$v7XG;1E{8(sQM{f}7T zUGSB}9=lAJWv&N^akOf^7lm5FpAu@f+7onwOEl)v5c4c5isxDM8yT-(vBcWHz37fpZ4#0Ft^&#@+q)_<^F)Vw3+ zV)1lcz9H)|Gk(~}wjG1+*hBq*_gV7MmF?B|P}1GM>uWys@6x*wCgzthGxc@nVggGJk~M(0OP`{)c9%U<5BIto4 zjDz5MQrZsE^w&`T`J^JetmGN|(LYFiSNRzR%woNtApK^p$jqzGz#7sgp?qT<65xFv zXg)tjk4NWN`XM*T}qY)*=^}|5E4phjS%w#R^CNY5c1&-Z4g- z&Z3{=f4AJ!U5$Mogz@jg72TTl3osU&WBI#hUk%JZ)Z>|*vHTwGdTb=;C7gBE7I!X% zp7h=Q{H$OHbcZb?M>Pc96}?(pc>;|#0begNek_}h99*uRhR-n-e+ z7`N@=&UpYF3*906PCaJ_x{Ng1bnCeo&tn~)cQAIo6twGci9XsP|2#jlLowDwUj&LF zi>w12`nS-y!eLT#oi<%v56;IhVsvx1SeXyH;aO+Xcu4=a=a^kmgY| z{f45N$9Z{qntqIVr2emd0pnw7?-<${gZ}Y9%28{7DSZd|ACdb#>6bIuso#RM>p5Gj z-@0^JsK!EaJg1?Z>w|F)3+GXAjHgNO1=I4v3m0Lo<6gH8pnVugzYSy!S>WRD&@a!T zy~4SqhJHu)ajm(tu=dptRoU0$=k*#~?+4_av~w)_M65rz+NbA8E61gCe#syI=*=ZD zJm}j`*8vy<0wmy5amqjYTtMCrQflL*$p=G!1;@zH6o2Xpapw(?2c#c!uO1%SQmy?( zozHn=v~fCT{}tocr^eCGaqL~XERtbEJ$y+c*TnkiPX%M4KpuXdK=Qa|8OK%+9?W?u zSi1^!z=HX@4xpcjzexJ)0Dr7=qwJqbEcC^($+3U^SkydG7k}7*p#RQ6&D0!AUFYff zTRpRX?#@3jI*;``b>4shWxjFbtlug38#XAC+&@=bA+Z0vGH}Kj+vN4v^KqSZfJZ+_ z4bgu=u+wKqzEl1&wHoUvVV4ff1D9JndGgP>YnVTB6XrZ}j;W^Kl>dRw{FhhN_JgPU&BW4w(H=zu!l?+-6VsjtAeWRHF&|3Pwbo<7C? zmk@tuhO%G2Z;!V9?K!KYAMM#c^*l}4d#3Imk$Z%kljF?6QuUs;>oxu_1OE~if7&)2 z$3AC{OE%V((#O;TEElAo^PakOt&-pWejzSmUkV3eW165pxV z4cqhXzF0rLtA{_^I^e<8T~Cn#uzd{vY7STK$;#GMbGV#(kG4H^AMG5a^HJ`p*ZShM z>(=2T)(mP+xyolwSQ>VEdiC!=3cWk^G84wG>tAM-|I_(}!CM%bRT zeUOzvA}ZGP2zXYW|oEpb^*|Qz;9pS(9 zQmy|D_S6?$AbLOh**x4>?D&T;w;%=jT{=@G+d7Doqy z{yn$!lo4%?mm!$@XXxiVx&c@x;LOu50{yfBUQzQHSr1VD+qIjn! zzxVV(+oy-J`#E=$boXh3eIKxI!ruopl&637XF09KJFs;<8?aTYNti43rq*c={p17Z zbW#>bC+#3ZJM{$hL#tM+<)H_OJ;o}-{x$TktCjvs%mA#Lr12;Hmm@Sj1-5;#_k!|&Xg?qlYKMPN9s=hWbFJv-Sa#odc9j$SSu#zwj_NHJ zothyxb#5s64_*Nyh8XGJo_*zLx4wowQE2y=I?&VsTirU)&}+xUy<^8J>DlXR`Rc1x zcp>NR=`|5l-;iVf)JpzIZvqi;>Bn~s4^)hGCM)3&EJv=Yi?&Mnw8td2a*oa;{+6@y z!u%ZCX+wujlao(+LD@i>2dD#Dpbo%Xa-#zc-K^t{PH5eF57sLF3_hRD>b>;AvH!vD zi^B!rzb2{v55Su8siEJ@X)v*m7t92HFcDEcu(l4%3go2HwF1=mD;go0YW*I`9iTy%zy~SIp_7|JlrmqwQ<#-~C2E*AnGCh(OTt!1)u9 z5z_7ip7Zz6L-XbE!^c8@dGv?X0hD*@!0haNdGt}1AwG|Gx1t-g+w&T2`Y|>SfGYck zqMSgSQ^wug=;0rj7ZS%LrY3BJEUKpC~SeJz`IiPmnuS!1qY!lW)o&)?R zp-kkVtnZRvT=@mc^DbQ1^Rvx7Qak2cJI=jQ^GiAR3iAy4!$y@)3zG%K1C|e^2lSjN%_HWQbpn4e*}ZF@jD3EYJoC&FoU<*f*HYdC zBXG&$#VTOUIk5xg5Qz?pW%1%gG32`SFo;z|7s2?Z?S_YgL=Uo3< zuSd;w&Nxq22c_q5jcbx~J|^~^RJ38OXf>yl^J6(rkaJBjr#Hs69XQ|a!w)}vED?FN zy;fLQsO>+LJw?Bd51tI@vH&`RasU~i94KA{u6gCd({n7}nEQ+Ux7NG!X$|d|XXw$6 z`>LAz8uN;o7DM~^gAb-X9!Fe?%t+}r=O&EBckmC)8MS2J){Qp)9{sisWLePXoO7v; z*{)J)px?ota?bhFJ9lzUvMK{uryh-C{66~Vqj=h(;Zve3j%ogc;5%IWD-8VsIY1o{ zML&7r;Y?Z`8E|L~%D=+D8u-KBXIaO7Z%PI*$1}$H*!)eOPTGUH)HH;r`ss^5<5Phr ztyAb=UH>cjC+%VMBhAr?HvS&{8t+QtOda6hAEck{PI=S3G6>weBprcV#S zJ)WYvOi7J?PX^-Y*R*q=F85jl{p1VRLm>WKM*#G@dj!s$Ib*-mPCnLzekJpct`Ew8 zxr;y7IMH>WDf_GgxgUo^e~hxm-^`ga4+!lx{aiCv%f2oDlzR_*yAIG;J9?hjGmu`= z@8~|SyY z>VT>P{fcyHNpOFHO9z_DgW~(4{0U9e*-db%uIQ=1Pt@O=>xA}$JbS6oc<7RF=;k~X zT+BUaLX*%V_E*5i>hD_((t7s~`e$SuX83Sm;szPmi?Z3LpU#vO%a_H_pH-^EqS7Uc z{Q>=WDvhx}EG%8TC_F4ex)&DU7-3;_Scq#n%t!2m1&Bk!0;I)-`ACZh^XGq4Y69L@ zhJ7xuHvB(twfTM^O5E__!&~kt;$U*We~G@Oq(A!+`#9-?`{#zxELn-qJ@?E#HvbRA z@x>Qk+71yZIYC_PN^+1kdenCg*>A{zLg( zM{AXYzT26{7kVwl@7im^un(!;_zxwPjvl-CvS|6Y^K2ixd`L?B{*>+1zm+ck)8VHl z+o?Tuna%&C{7hUHLNQ7tyXsmL-=fkJi4rRwNCYa3UVF=h33B=&lyH@jnWe6fiND{=o_^tSMl;+3kF6pHpO(s{|$!!l;>n2 zT94G8w~6IHF-~|k3im*Ioy&iUybimK{71jGDvw6L+i_x=!?36g55wN)PfJD5nEXfO z-SB_>L16y}`ETaT7#dQEM|<;?kUZ zgLs8wVd20!8}rjtun`gXN$}A8kIDG4V-I-y*C+o<_j`2IDNn%b5=?L42>#Mt zTcvdOc70sV{p;#iX9dfREC1tb!T&m{vNiuVMC$)2xlcJA;|0s$^WrR+vYlI`^6iH( z7rh_mr1q1_8P7^7t|?5cvJm7~U7yzp|HCTN$h2s%>W%*v-^+Jwl&ZHM)axN}eNWB} zAKgh}Sm!hL)D=>`F<;|@d4cvipUE+{*ZYgsk@kCN-=)s~Xud_VI?W5@|0ZWa4?Caf z9b0^`_+Cc7zlF7!xDFfluD92u;JkdUPsMebkY4riGpa18a)SAz>EVzX?@1HOQtJFq zlbJQRz0Ty{=KIb~Qmy12d{^>rt%YIMgs|h$xX!Zh>{rx_-<@?&6Y(8uBd)rxtK2cLnOYyR7uHBT z*i=ccS2DE^0;M~eU0TbJV>ly33mANv!4EQYFS|I}+* z-IOb_gnGu*t08fK?{`Ap2VnhM!*{(7Y-6r5ck9DFZ%N1A5kaylL#Jbm<|41yb?jhy_($?4I!fVsp^=~5i_e*IQ=Tb;Z;U!m5 z{tf?~^-DAk$UE1P2JfrK{$Av|mfC)I?H20o5FR_S?yG+d-=Sv;uInOW26%kuT1C$j(5>Uu5Hue!>}KlrcKZUF|g>mc{#Xh$i& zkM8WPr5DuYVdo+89?o~Y4)KM#-$fZXUe3(E$61T^HJA5XkJ9F~eatmlxdwT=b|0ZW zs8;K=ay{DCtv|(@UdTK2kSq6=Pc$w5*OI@<9{2Izk$<&*5^`Vl$icaK)Q@1N6OGP7RuufG_uK=1si*J;uNytnxr zf6Vo+8_2NSCRp$GpWyvR&^z(_dc^ZxuLDospLynNrC-9 zly|!fnDHkQ>xBQhc=)-RdXdrmr>#K!gY_;Lu#SvcNB!xmz<+BU`CuIY+j8FTIx2f| zn6<8~pZDBXM7OC1=X7@V6$z|2kM(vn-{WolR;^x8Yw>pNx>&72XX;tQ@35F_lXmVr z2Rg`-CkC;{e+FM3p`mP~Sd#QMTUGLapiV z)vp=@^8RQg_h+F^#WkK=X1(j$H?BS7^55W~WA1Z8JEwQ=^`W{xjQ32V4V05JPi89{ zi2VrR?cYfUEB~vomm2ack6bLib@XldzYBE%(sA*4m$bAT5|lE_HQ}c zUi)@qU#|18j(HIpso+&c`$9rkQmxz zTV(X;xu8n#fnb+`I_G~)l|K}c(mz-ZbqGd6oV!1keU;e2;bBdvRAXAaEit=d)E&hhJZ!*k& zZ7RcW@|FGt?)AXEFj&tr&0IIJwt=d#R_rH^4a7RYvqPigzZPPgBDdpj)BJbzk0t*V zo6&x!pBa0ghPHjVrv&QYYr0`i35Wk)JBzmF_td_DEwZMezK!-N5C_UV_Z;cdXS3Gz z5j^Goatt@#2+_Dh^Ih+?;9y`q*RLPfDV{AG)?+^mWdrH{8QcH4|LWgpWPSWM{vYJH zVl~?D^t;`TzLqdQGx%RQrZ@KQkA?@hc?azq8i`);Z^%Mn;D1 zSj)4P_4)H-$esS>%e_e<|GAbYe=L_O z;J7jmbr<)KU|*N5cg!*NlqeqEOD;X*MBknoM*nkPjlMZU75}s2_7(GV5$${Kt41C( z`1t6YQp&*{|Zo47UpJzlad z6?yEjIM%C#rAsw7tQYKOh-JHWo8^u%T$&Gk#Ll=0J^qarhR92kCe?xe!ANq=Vo(0D zZ-|nA^r0eeRbRcX+rEK4MBTCgzr(hhyDL8%_Y^U5Z({DNGc32Mg+rkeSdu=oiL5NTx5+%)fr= zrS#>$rhJY9@mfp&tF=zcwePd)BPiD39-`#Esf)q?Wq<4}gChCQy_JrTj@h@O4hY*z zRpZgB)db~N3-jwxr+0=4@WvaVtXGMwgSj6?Q2!7ImWfUsXUqC^TX3(}7gHPlNAa;&mFKx>eD?(YX5^uiQzxN>hKQ@M~+vLgQmzF60o4&AviT~GLdu?;QcaUR$Vm)Og z|Ll8FzW?5y&bRdse4p=K-d6d3xL>)=cXLer!@aCqWxb{P7;V0rWBPm8_UhYrm)nPH z_(z?>_EymDjG^*n{fnjU)xLOP_P=>YZtE4`KMhyz6Qlbg4>tZA{iA$8SVOuT)Wx2? z#^FC?-PSueR{m#<)qDl}3A3-!>?dsaZenc%HNl=zFC?&m*#4S;{j=7_)xm?%hNAwb zjxj&--u!O6O<&i%_U}W?H0q!1>@TqQ$Z9+iuYX>C>7_cbKWkSbQ~tMXW&H&C=epaF z|8g8xbA0EYnmXft;A^lkU>ywpk5&5_ga1C?|M6~Fpbd0B`d->({R{lJ_ILD+snYFV zv&XTKTN87SyK5i{!QT`N%^)^<-&?=V;j^sIyL71l1|LeSsu+8w#M?i0!~dGry!tt< ze;od^{$+rSRbbp_^>}P>hTmL%xSVpzYVD2^(z?fA|JH^4 z*8~624qzR?Q1$hjusP8P;&@xt7N*|~0rt;B+|L=~9sKftoN5nnjN725-$n2AUdb1F=m-Jwl(S;WhUFW8GlRO<7^gb_8)b<h!Un1gTCUCZO3onv^< zHdoW8(=j&sOT~Mw_r11no$=r0VC@cq{MY-9IC{v|N1&$aYgB|N3$dG8WBzDwxvux= z%Kqe-aq_+Y>ED+-y8S>3ZvfwCK7j(X2TzH7y>Pdje%d6l_xCisXRK|Y7ej3zTehQ) zSq_E{73`r|1Yh2J^5F+7)OYnBpnCkL)<3`j{Q$=Psg?ZKj_vN#HvgF~hIkC1k5Cq% ze!;a_@ULx}%CcN_Otp2fn`PDccrj!lj1#nS$h1r=DFs--~N30<%2{2 zZ*%M)!~d|Dwvoby7?8q+@5;KHddo8dkCqkJgYUDRMgiHUM&GNt1MN6H&ZZI=&<=T5 z?dzy{uKVEK@ee2i+7G11n7F#dsZY(m)`sUMCg0iaZPH{B{A}-|e%|Zwo-*Y4(h+-O zx(`nNZ?)}D%D)xjupnKW7eGpjQ5M#GhGAwq72j3)aGo`L=v1ryRb%q2t7gh^*i+J# z`M~%GwgEW4D#{N>p1Zg>^(_59r=30>g7FNE3gxlK(oa ze~tXxd=EPgVxi>Rp}YcqJD=VHjl{t0KY8!p=EiBv%hdH< zx|E{7_)c7Cs^FI}SrgD#~*;6~9{tx4SCHIC@{L|%u#|9IV zo@xx{$ZM|_sQrcQ{dMiK;Q4_nB5fe*`g6~Xp)T2qw%5Bdf8H9t8f`0L@9CQg*qf#= zgEA4Ve_pPG_HSx@j=D?LzX|k@;dxkW`0tZ@V>6i;xKTd8{%W(FaKgldHW0@^^zFMl z&JRT0LtW3fMT3w=pmg$2A-wbb4iYi-UGV)=w5dMFe(Tf0Gt;K>Y#JCmE-wvFO-#LS zj_tUlc%KyS+bhMGqg{sjfxlN?nS4jO{7U!!WT-{+-@j+4jg4Iv6rUa04q^~i4%jAO zKaZ`uxo0$Wy8czdu3RT`=M>_UtvvXS;g2nIo?$-bp+gw^opSF>73MgxIggHK;r>}N zckUcnvwF2W`=9^ZpDsV^`u>{mpFFTJFnqUTBmY4RT)bQibU87Y&{v`L6Z+t)jlR)% zQJ>JK^Vw&g!Ov5u_MBh2vOrd@T&Y5Se!k@8A7$3v}w~~_uhMFm%8R>y6^MxAM}y0$@Gt_Qy|O9jw5xD`>eD7E%cLVGnsd~WrXDm zx}W@~9$AbIVeWB`J+IZi-_G9OYCmXuA9(H$zHuYNMvTKQ%QxpSx4^Iq)(ZZP1U z@IeeH_r#XG|I1(g(z$LqnBZ+GKzRfBzuoZP=Dp&13i<_nG<;4sCIffcn{%eRE7+ z2J2vT9xDI-`j+Lu){b)<)goZu)Yd72UeX9Sxglf(HBK0}^^dO)2=rN`-#i06~sXUc~CZG$EC zPYK4w(3a$W^DG0jgY0s^x(tDi4)|QZtXG{AiX9R|(<2aw=07oD2$q5H`Z#qv$k9i@ z!Ss;@<=ii(PS7*PJ9k2LSU{>srg>2SkiQq7+7o=^2CZT=IF zka}Q$^S>DDyY0cG47&`V95}rHT1MTtAMKn3%ua@*&HpOMJA+#fB+(VNj!64BLXT1Z zguTmde^LI6i%S&$Sq7+sfP>;aeG{V(LOUl4qJ;c+u}H3iqU7JLm%&%h4uXwe3;jc% zLJ9m3M*e|;;{E*j^X1Mv6Sb|PxPbIzf|}sb+FY{v@5;T)cT*N>&VM7z9`94t|Gdv? z2V3@!l7EN)z`*7Gs8I)p&PfN4Apbf3S=YI#^PcrVl>FD6|6V&-wE=?shyI5Rq54x@~oU#5g(2BQh4`zrPFw~$?PB6pQwKo?-zpicitK9$BZDk z#=J`YVET7N^FM_5iTJOsL1#d(+d;{{Pxo8;$I&lZ&%hqVec`<4HLv{z{iArlV8H?z zHELA-`g3XwVgQSX_x%3%H?}0*>ZhpUk$a%i53;{IckX}>VlnOedhqAOGs;bI4+6YR z_tbjNefQnhjCRACHEUw%(1uKz;uoA}z`ZyoQ2>D3M zXROcjkyc>N5$7S~=jWB?=LN%x(!4wdq~o3yE0)XDsZ;kn^w7Nte8`$17MbaCXusVL z(PlP}fB}XOv44Gf``SmpC+hv_r8i8QIiWy=CMI}wy7wn|)wh>i4jjx}Jy3bm-`x*i zcR%4CxVS_eq<&}A8|piK|L3s&zF2>6t}7JM%3)eJ$LFw?kH^2To>C5#eAW~B_4-=P z+iy8suT1{-<==APTMneef!4Td4DW!qvy^0 z2J<`SRLq?-yL?V$n1yQyv%P@1nB@$!oLHZKiZp~-UYLdB_%IV`2%mc4QylxlCpdS) zOvF~0iDNI!z_AlPLF@+HgX2%a!VIJb!VH|xm@z$t@Dc7Yf%ktzK48ztXZN;k+m_|% zkoi6F#1nVN4Npu_SnDY zTq1sOO%nd72M#^_|E2KfIKLy7^8Gv5(7d{CiTxzvf8!ajUB{qyH0?|AT?|bt`Z2 z_>soie8b=meD`bpUt4*4!m0o30{>+EOLqNWkh4h@Ifr}3YD$k~4${?Ybd82x5FzOZZmJ!z@yf|m(}3=NFaq9eo}Obh?oR@!xC{1N{r(#JoDw~Z%x8NAMOuB#W8exTE8r^h_~ zkQV-Rh+l1A<>Ak@;b<3|^6c#QtIkbYxZPZP3>`k!K|LIn7h68VuGdA{e+T|S%>Cz_ zv#;gD#6})?ZjS%|j1P7E)!LA#%Uyk#PQ4z6eLa=s{V8YZ*aU|z{!eKCKjwcM?Csdt zNQUoaz}19u=a|>_hmNgHn=~CP4XshOjJd93U3U@xPtbRW`gs5Nf9lAFME_qx{B2oC z_qdcgMClPzU)XVq^+a9hk-B(Cn2xj34%PDiR88@REl?wQh_fLQ*$#=Wr6@lZM*ebJ zo99{PJ=>q})%efYf91cnhQITC5PzFTHPM;&^I^v+)f3o@h&`$$SVyB2%Zh6qk_uja zOv1ijUundj<4N|L{@)Myr_3u`zdFZJIG3TUsDS&-vHA|{iE^wD2|kuqU=QKN)1~s$ z@lv*7g`1Xge(I&nCzik3;a^9Uc@1RZsi*3%{!h7nz`C&9k*5^fYE&$EUt*74hP|WD zz@DwGMQ-aRmG3_;rIpoM4zMRr-O52Soe_RLs2gjCe+_`E%_Ek$9Z>wy<{<86RWaZ` zP2+x3Tai)i^C`1J7a8Cg6i z@wae}5|;ylzf;~7?(^RRwwH=1+)?J$9?jU8(cZ6|a&R+b0(%Enz5l3`K^G{w!Mbm$ z;*`#P>Bvo~Q{Nqw_(y?mrSBj7oiY`QdmEJZGl6?AKjt?6<`{K>)&)P2@->Teok3X$ z)c3aSVIK$YtLeEdOXeL4e~dE*Yxht4-@?#;m_nhq9(d}hr|PZ!@A9d#3RuOoo_EW; zgZmu`aK9^{2Pg+d7u?cGDyKcFY(-@=D!U-Ot~MA&$2EP9q2qjI%JAJ=8Az;dkIho$a(iKN?LZ|n=Z{MR{1_4vZ4-TJ@M(wk<_&tM#(BM%Vn z6}R`67k`{9m$W-U&T7_3p6P$2$b>(b-8r$pRFN!MyaoFFUhH=PLjsX9?2J7^npwm zzg%`~-vh+Bk8c(B$<+IF#noqq-jLYFp2z$hboi&B3Z8x%?LP=F_h$0;o%iFf>wRrk zYkwW~8f1BQaSyTU40b8Tp>}^1?wQAk!JXKjpL?vFm;D{Nrdu<)yzisZ_=H!bd5g)C zo$c)r&;8;l3n!d7QMg}rn>Me?pZ+)pn5~oQD(+KDS>RsG)Ng+J!_GtTKLq=K6#k&x zhrcr()+Y}fzlrI!~HlJo_ma3l$#;v zw$jFVQa-ooDT3*^{ir^;1VO;<8dRT}|rbU;V<3%J*I)y&mP`3F6)cxWk?w99rHz>_-6i;kk{aQ}!*=JnJKr`PYGc zVx1o)2bq~K0sFDizWodtG^kpJ4HMJ{A}5{nwtPIj0F;|?@t&`@=J=-witjeaOg)NC z6#hyEsQ<|U)cJBNd~CO)|E0ENow86v+-c`snA<>x=C+WwS&ur{zgpvRpP4yHTDBY~ zojcEybIuVJ1`pP8vu0n)KmYj!sIO8!^C^^fUDr2Ob^XBH9iRud6kke)pY<=k^ch71uQ_6Gl~tYvcJjh|5Zm0l#)tPgF>?cDe8G$=O2%{{z#Pj@5os>7onbiM_M98 z>2sBCwfpS*j7+p_IUa4p*W~Q8cgWD8e(VkY9Xf21AN>epQmRW-`_Q$8;$%Sg9UiLq zKNC^^D_a12DVD>>Q~m63bXzYB>xeXE@85lv_RrS{cU|9)$UR)ydR?>sfHMC9@P9+q z^){}lAJfhoD{b3OlYs-uRk=51-j1mYdiE?qz3{q}6z>JJ@#p$vD8Omx z|NUOv<35-ef7E@@0kJ24E^FUzqz{ z_<2s2GqUe<>iXAejD2S2iwgS=9p)-sPn~aLZXXl>zI|iR5pPR=-ewnngLPPZ$m0Ku zZU1}ox>fl{e=2GAbhiaf1PK`d$@aYVSGA6 z;M2*4Plx3`E9;eLogbm^jb30q-?HTd>DFzjD)Y4UY`lZV#DBm5L0fQ!y!Re#-xS&a z{~h?3`r3fy#2@n>-V5T-bIL&T=3mGYkHhCh32^p0jj{pj3;(*d_k$Hc zLj7MIlk#Hplibr!kHfVwjyi_Vp8mUwVB-xyxlbMY`tj-H=Dscc`t4QNhv6KRMjgR6 zz+eCJDWIi4SnubY5Pz&yddSuP&phM!e=z3)<7s^X_*d*gpXpuD=eJ=Tb`<7zI?Dc= zKWnZ0cjx7VmGQ8&<0Z^_tB--5rd{DUZttyFzJw|7sDKkYt*KlM6I2DEG}=ciEq ziT&W*F7WBVK8GJah-W(IiF`U?I47(BO&g$KC3K+U2TG{_XU;r?^}pZ$ujGJK=grl+ zpZ$JeW!_F>n-9L9Uw1u4+5Gg|g_U=cMx8$-_e|Ay7qs(&c3hYo*!Lv!>4f2*D9vsI zOq-eqPE;uyaKGSR-$oyeK<69JnEK!J|COT+pn~@OR$?9|=iP+P%dxTNG4PMw(MNvP z;Y8JkV{i_Ohvy!pWTSP~)3q%3#;5bX3>+Bk(@BJN)HTxvc>J-ifIsJ%B=-Mu?fOH_ z{(H{FANC*p{cb2%zW>T^-qOCmuz6W=_@6D6V|uIp+ac(~3&UNfu^;C!>78?_^6MmQ z+gokCpbkjKrxS&7qIA;+_{(2rVu+O1k5MuJ0TJ$H@ZW?#eg9Q+U(|E&tnnU^V>!_F z+jDDA8Q%IhIS1|jDEx=#9*uV4(b6XCiR8Zg2>d;t&a#wzItJ%K7#_-C&g0{ldyV+B z4KQ-#```q*K^X|HldAiLaq-Z^|2e1qR~;z-SPx#|KjT?FwlmoG7cBdCqHhrWJe&X8 zUHSSF;eSC+LyXsIt?K>E>?vv6e8fI8bF%dAy(!M86OX6Hdf-5jUcCf9eUUC*1bsh= zWI$ijZGd9w+Vu_92TU7~{?ITTaH!({jEjH3{=>Krh5xjF>Aqj$8;3dSd^_#Fv!&p= zE;2OxSUDFVs{FH0r+-dAHD)I>i$49-$852E$FUxL`|e7~r$Y?;^%Gzy^2=X}{O3O+ zk31rB-g$yPp7_2$TYmVgX#*4%unnO5fi(VpTOb+!`@^K8S(onbIfH*-{7*UXSAG9) z-lykgr-XlBS@io(O7~L+qVfN}^vfBT82`|A|77LI@6~Hv%Gk4<_dwHS@?P5RN5;RVm8bt_V?QJC|B&kcd+xdCl>aXV{cn8SDn{RL z1t_k5%l)jv z@IP>6KlzuV@rOKsrr5+A)LJZ&HlMBkALuI||FXUE{S3>AF8>_QF)X*4!d;KuOGW=r zR%3R0^;)a&w=s%3?u2IRKmK6>?pFco!WCDD^oCs!^$yyAnVHMvrkmIgP%YJ!-dF&; z9^lKk$5|Nu>FVT^?oSGB(i_xgBJr;R{_y|Dp8IX;V?WU6`_BKgRdca^Xq9y+;pKm_PcN{o$NL5hD0Ro_MPbJDJ}^y2jDW_jBr=fW5bT-$@Wa|3 zjA|3uZ2*o13eFEPGQfB3SNu~FlH@l<@Iac zhwR(Y{(INO+4koJWrB8JHrfirmO42R_QaAl{)7ocCGl^+DVZ6ZZLQ|4;YqYvB7! zJdV@I0n0zf>qbO*jbmhshN2@7uRpjRjh>nlH5H zNozTv4Djcc|CCg3N;f4hz_q-?lz$t4B?nb#?Lf}ufuZR8M`5z-`LJW+5Bu+l;hAz4 z`u-B*&#_&HVLayulz)!zNIr&J>;K8pzyH3Z`kyx4AOENzN&4sc=aZLz(+2q9gM8%& zG-cn8efZZBlawB>Bk>vQ*#Fhl)$skI&1VA2OgYCs-rX;?u^&#~@54h|qVKnHQv7ur zpnJ+~0QTv(YBdQypN$Feryi$|=jo@DLa%PP|9;&@@btgu|1!=4e%t}0N@kW zlCZHabISkhS+jihU#%cY>*2I+_KDZnv8Mfp`X1Q30hCszGxmdhzwrOvIjEVk|DxLd zCSA7ynyR`#wKf3T{^O;4_Z124f9n12=&Fut^97~is;jgQz+fE~n|^@5{&gm~0v^TB z5$1b?`1=?gI0;28G|_r6*>&sIR1Z!0k8A&v(#@FvM8B^)_9KB0fcT^T@8+|bNPqVI zC#?TDZ`ZT|{%y4Z{5HR-`%O&zJ9hjkVfkm9pSJ&JpAnJ7zb$a-@qXG1VYr(#;(x{& z8|1Q|ehB-oG;Tfsbpe0r;GZf!^?l!S&y5|!yvLybef}S$Rjyem>5l#QV^3%7M^gMv z8(@sD4PdYii|PMq*KWGdUq_v6@Udg!c@{+ROHSR2`}qyQ7tyuFc7mt-!{15#J9pkL z=bt}SDl3ZBdJ#VTZ}7)H=)mic^8bw;Yw!>F|G>LSj{V^Jt7(k=_*IvaG4>C=OE1N}j_)VR2Ta)_{vA4OmWwa; z<^u)S!s5&49IE((8P&o5KhRUP@O?e+Bc;B7J@(_WGZT&d@Q()ywgG~82X%ng1}KiK z16Y>3!2WygIU*AMn>;zO-EZp@`kS+|mdl_2JOeMRgboO=|K}c(_-p&GItIHIeZD5J z@2_$h>>aMlJ0^v(U+DX<;Mfn0`}U3fu>62t+1Fz~*#;Px(*tdQHxQEd1DZBKpFW!+ z@h5I9|Fr9(erwn2dOy`ZV7~Lz(>|0buPwtX!`GhXw+_kjZ|Z;GUjh8x0G&WPSIzyo zA=4TEg^D$-?awseUvx|Iu^+Y!unjOYrx~z+S+xU$__3F1kqf`$W|tcG>6r&<-88VD8YXs@=ymL0I?ySLuJSN!$Or{a2~lftc@r^opYG z5_|qyto@Ly#{ov+kG9{YzjlM)_juu)9~+cTMZ) z>!;vAb!feB+5x!X(A58~{U47%^+06>WB~num7uS3_ZEqb{X@7e0RD@vO+5C);6A8D zBe|#dF|zJ=*&^@$LGHW$4LSORS5!R^X9EVtfuzy~u(6~+i1j=JkL_!NArpfK`(=T0 zLhQSC-6h8#|BBpy|7V02Kl**VzF&j8iFwDI*|QV2|57gVDc_iOk&k;IVfm*HFoCuK zXk^g301P#{fO-MG|Ie;IQ?0|uu^+b1SGc23$l$*Ex4?b;)gr4uC);G)q_uMVapU3N z)&8G28L-BOrP2oQVT`hz>N&9=2JG9mU5&NCUX)i~S*r3EwhoxVoxWcl^Op?&IOf+f zp)SNEW&D*KKo_uI5VTh8-YT(ie}W7+b^-eSKe(cu^us!=KXiOQv;)`|Y;a#=a9{Nq zW>aWcBHw1u+9*x19v|0dOr-mHE!8$as+gPmSVu54Yc>!4!INdd{0+>S()sM)bMXe& zVaL2TJ^bqey;=$o9yj>I0RUR5@2xU#%6?c3dMO80U%n2z0J`9|b~5eq*3$L#MoI^; zejnVjk^HSsV^O$I{H@5EFBI-de$d}ce^BD09j1=h#o(50ICzAHd+6 zN=!SCexs94nl2Yy@TTnCS%iFnK4+|N7~a7&SkN+OPGbFEd$j8Q4db2q9}NK2_A@dN zjNN*`k%damhsXK=tPd)o3uMxDQhI%k{HaqT8Qi=fa7X=qZH@?WU;CwnyOFapC2Oo# ztE(#DvwvHy(;JF^zy=`xojQG0C-}3j@71eBjyvuRx#9YcK@Bw%$3B2Q(r5+J9K_w6 z^S!V~!xZjuSx9`lo#?GT(_vDjal7Bz- z4Jp7+&+)Kvw~vGE{(8ZGe*o{O5`%v%=>H+EfnYGRW33lZPAcI)B>OOqTuO^v+?np` z4(EOJF&ds$VLT;&cii!{T7#AAG>7T{*9M#n8z5OffFEt@HZdU}fTZsQ(k-Zjj8EtIqdA=qDX}`pU>bupZ!h>K^~P$2<7O)XIN+ zU10Dtv5jrgWANGB!{=kF4X{V)eYOD-wf}Y9kX{?W)b+-v^S8gv0a{Q}sHOZk`|Z83 zYX<*dtnr_d(Ek(UechecTtCzm{$X<9l^vF!kl@qPTGwYx_XA0lrS! z2B5E(<(@vBrcEcwYp*&!9kg7i-_<&I(Diog*8geL0s900nxh;oX)gXO3&5k+0rs3J z+l)hTw`BqMRI4^XwfyQ=Gv$aQ#-?Ng=(%9gZ2B>Oe zu+QVL3t|~K54%4sP4_WrJwX{L-d7^y#x0i%M|>cspZ+4+gyU76(6Xh!UEuWt zl&ErU+xwnR=XJ-Y!`>_H(+TtIB*R|$x*XduA^vj@RQ+$sLYg|klz-xHV;?-GEO;UH zdcrR6(RG1tU$RX|TR{J+Wa*M^ayRyE>)!oM)iyl#SnkpCqN*R*4?sUapFW#i{EeN* zwYyI~`6YS!-^QoIHT=!mhGBX?uWq_SfTA~BXd(if08h>*S2K+5iNab*% zRsMsRN1baqNG=1!K3L`>Wx%=D_yx3IklsVK&C4;Kf2~~o>ra(!$i3W7#6Dh4nv8{P zF|3dPMoOxKhfnsP-lY|hIe%u2kb_+2l#&NAEZxS|F&%2gSkCRf%#OF zaoP#5K?e3f4^(3xr`hlcy(a6{?NB$fU%}WOCa>J*rV92=6($Djn8)b_v*x26^MiBe z90dA59OlT>iQ|*u@3#qpIw4hEVC#WM9pIETqXSGiG-Z!?>i!vWyGrJLvqeVz?_BBJ zX$tCsS1>;J6O>J@i@kNsjJ_|!I<8e%%d=Q^@7f7_a92uUchN4{jeB?Ra)LVEsouf& zm_Ykt)~BCVfYAr%`2PsIAX)h*zCp}`=c&qoDT9n7WdPTRKk@V9&vWWut%GCLlm+^5 z(A|LbclU9CpRygeMw7jcoz~aXZ~6K8^5&awVqEBZ2vgG!@0n-#yf4I1_T|-AU%^^o zBM(meBQ!uV{DW9Wou?}UN-x`f0CP;)aAY79ev+4Y9}LIDpV$+pUC`w_cI?3Z;M-N$wr!i3 zuyyNJ*@B6I44W}noMF?ZO|o(0Min+}*dXiIuQwsa_w3oTMvRl;SW2g z(gdI0PhAES)^WC6`16VC0Hp(>>Hu2@1jcEEmVdm5;~PxbC+@`A1RLuxybb1*9fq}- zK(c1dni!v_EIt4H^DoyiEGfUPwpzu-A9bmVy^#UNl!IXGlZ|-$AeFkn=!8Tv5M>9d zH40QaEZ#;i?J=MJcko}dXc2K!WuN8U;LkW&nWqeFK)H8h;Gn=hWg$s$V^aKWSuk>t z0{+C8WjHE?;jibNIJTi<0}#hhJHWv|=+8@tKlDG#?#`V%u{MmS`;{DQ)B3>Zf-TW{ zKSuqryJ+_TE%yl^P3uxh_ydc|Fr6Nj#(R}~gzJ2RdtJrKj#S^&-=nOAy<6A+3l#p; z*{1GiY}frp7X<5mRsJ_@Q1yLLQPF<|_7s#!#{Uzl|Ish2%6zI=hVwoQkA!J#3s{&& z-D~QO;C0G?t^fHhwiOql{L^;Zy?eK+18f`6$bqR7j2^J;ff)71UbHi#>-*Z)#%UGZ zI=Ok$q)FQUr|>7<4(36_-i*>JpT1ik9)YMR#zB#F}lLUD&5i*CY`Y>3zn{-{$IFofoj9= zfevSRX7KbtybM4NVr<)&V4UxPz&_m~o$mdVjO6&&r2Hqc7gEFB;8~aWQ~%SCL;Pt2 zFoel~V-Lny*R$^zedGsMxvvS(5o+KCxBidP|4HhBFgrlmjM^`dN*&E z0U>QbTmPrh22}PR@ZYp)v#Q6b)1&Z5+c3tup8W_DCLCngN8nh~nUF^Oqg(RmHV24{QufJ z?=U-xE77y>z3+Sf?zi^a_1f!a?|YgV1%yNpC|D9835>7+vukYc+7GZHmWVKlN-m z(Cd=t-wwJS-q}^FvVZm##d^dO5ATvae|QgqzJM>seLrx}{(RfIUQYV_;k=Ul5B+~C z|Dj(GeDlx0s0Z3X*TdhvYSpTIb8qoQkv;$5+4Ju~Q5JYQfM4d`kCL1SwxyH*#Xg`# z{jYfb?V#&hX?}?HXK(RYg~_$E_AqDvg}D!&eM?Cmi29!%BYEchHu3p`tng%av1YQP zT=y4kfg;1DK3(Zu0JZ}gfZt#0{}=oIAOo2FCq93w1KQy_F#q-HymN2ok%zAG@WT%m z{eKqif9{iLEqlg%UF`pP&!2zB{xQt|C))o}&)>8EY2OaGhiBhT z^TJkl&}R>Ck>2wc<$iIUd?x$t@_U7%OvW>0_W!tvqZK1N2t_Nl7R=T`(3oTG@ZH9HfY@szRTguCp zEpIl9Wykg%Eq{3B73$AlUX^*euHMW?9(m-y(`)}vT43Blj>}&qzBl^Tvvc9^jQCg1 zN5sMT-c_xCO10j1s<)v-{?VygEX$Me={Ra_Y*>SaRWz1jv%!V&(3CA8eFV~tDSpWI z^-|nQMNX~A-(Qg*jcMNKdy3v0bLm{~uF=2D(w`N3nb-rOAAZWy>BUoCA?4}Oe_c-Y zc7(q%CrypFfh~Y7fGvP6fGtoBEzlWm66}Nj2Gv(#?VNMI`ak@-CjLj`C;yKTpHcpw z5#AxT0JZ?OK=KwqDWAOU@Jwt0YyoTmY=LkK*w!hwuNS^sT*DT?7Qhz37Qhz37Qhz3 z7QhzBt_9Gx&91HSm#_t}1+WFM1+WFM1+WFM1+WFM1+WFM1+WFM1+WEdw?NjuoveS) zc6-^n9=3z6mc;8|3%F|mV(aR4VSRt@HA`zYyoTmYyoTm zY=IhTff+NVlNmFn`DHq-g)*J42W2XmK22Vx(d~YjLhE9gN^41(I(0H(Wy%z~98soB znM6uuGF_IG$&)9NQkg`TrDX!$K7mZ)%S5^!D&y(8UnbIe0xc6K$jUgn-7gbpT`J@0 za;S`@>wXze>+$0xW&9YrJzU1ob+3%2b*YS|%jIP(-8Oa%E2HRgc^N~ud1W-MOJyWo zE-$0$w$YKT3trceIrNwm!i;pBmI3N?!AYIrG_xe?VketOTR7Z zeQPe_`y;qtv)$mk9@vhiZ2+<$x6stkjWPbues+dn!8V9}2hO^0Bajuye>Os9W>Npg zK6jK4C?7L=2ax}4*oR+1{@v>{l&xlMjQk`2$cz~?wbWj~Kllu@qoCvdQf=w~(`ch- zWTX4?#!Y+IGXHtYK&oc`Kg;x8@3{InYuloh`L}Cnq?abu%zylQ=|(@r|1QdkG;M_Z z+m#YgSDQK0kN>B6UU)9@Zu1>L{-cOB*r#D0%$zyH%TG}~KeVy)%XZe!Ch70#pXb|a z$iD&ODG!a~UnLiCdw%6YWu8s3uVo=W2>DM5wZZAgfBrev*PmJaExccaU$*o6$iD%= zDG$9b#{RF2F0u0-;k{R9J0kxnp*A=@@BHUa_f;oT)g%i&{^__ugVz`jP1pi?=RcG8 z) zz~zo93u==8oEjnb-4`AIx5Z$grJ2Zzt?T1;YDZSoB>zd2a|Y*R#Xs_yA3kMrm~G3$ zb1LUQr^+(}KHdF<-=_E7H#0K-uafVB={sq9eAM5Deu&D+t8)I`QP9z^oM--{_{4pc zUQq;`v120*Oeje-o=j0scjA`)s|Jwf6natIANpB|5;tO(;kPip;FxC z<#&O9`0hyDyNmnNw?K)1;9Ivi%R^OGYt9y?3qbH)=d~R}Fu!$Ny;5|8}K-+-Vrae>J=-F7DB#ZR_HG^LNd3vKROt zgZ$gYUvV{4{*jG5+SS}W8QDnOc{#FskbecN4mL;epSBM!KmTm%JOTN2E#x22zK!QM zkogWzHIkN1kCV0shm-cX-zRO$enJW_FCU{{t?nX{W?NM^(!mwlfF(`w)`$E18nPH+tft+(DW6$NbQrm)^711_T&wsW^ zr~0*7vn#=WdRv`;m#i)uavzcdo-OeE1pS*TkcSTJJCzJT{!<}sbh5twukm;E)NxW+ z_khyQ4{?ihIE}XnWu;Y76YmP7YK_ekRx+dFJ1Q zp=v&Mc18Su6|^NX;yz?Ape-Pkf06u;DYySSicZc_o^%-glX5@0Jmwc-ThRAW`@j}x zI<%MSJ+DU{{e}??eHr4+I z{Kvh&Hs!;~qonYMC8T}wRT7pf#5dzSrfyqtJ8AxOw^RmH1JkDW7$*a&ng8nYQU29A zv$grpsofvRy*=NZQisQ|t$kjMEnv!6o{v}Y&&HNkGymCXZacrE&%Z^zA7>Zn?5`E> z1^LktYylPjY&-IwhHWzor0V#uQSL)_f@ce?t;zlXV?3(%`5#?1`M+ssg>&_Y)jP`c zUaE%w8t4AN-4~NhKkh+3{#mb*|Dy2xY%V@f>70S=;*=eYe^X{{9v|?J{riv(7VPpQ z``R_GvGeu|$bVh>W%7-NzbTb*L%u?q@AZ9_&AT=VGQgf3VEyeVU2JWi!5ju2|6^79 z-;Ex$$y7C6Yfj1k2mHsEW{qR4_?~K9-^;k?Wx%p;)_Hb7>qEbk@c$Xvx$_Wt;rZQU z{TrW<PuznvIHtc!Qsnc&urCYbP*`X`N)jo2e~u3S4%kQTBW;2I=>0`c{~ww0 zWAdGEzhullF9*&$?=f=s-5aQ{U|&V$0P>$5*Sfzn2l#I-kftW8|5YW8eizS0?jyNp z`XBlV@7{GC8F=B!RP6I|0G0<0dYa6d^%mK?=UC>l0QuLXGDBBkX8fN#f_6R^<71qw z>L2zVM!I)iZIyjq7Sz|TAvfRj2lB)dACShAr&GuR=r_jOhq~;;alZXOro-g_Xi5bq zhiUN-c7et-RvlB=@_W)g?HajXuY>mQRos80;d7*S-A&H8=Vbs}zVO1w$vyXMB3rlY zm--A%*tKyi^6vz7=|#2qSM?2|wl^LnZL5EQ+-vInp$&gede;4CrtC8r!0MqxpCtjJ*h0n9se2H<5EZ+Zk(&r|Bqr^v9I~qLGL?RD!+KOZcpmZSL=H} z@9)ZndeW_KW<}X&G63q%o!5|CZ+(qC_v~((Qy`z8Ac}pq&sYY~JVB5DacKWL#J+TC z7yLKVZ{oGj`PbUf`!x69&0Bkr;r+g(<-3bxUEF_peIL@$X<2UA=j8w__wM~WGHKE_ za`@0m<9=Wq|FRrpn=~mtpRlIbQrP9s7ZQ5a!?!8LE!;@Ek#|xxc#M zZ^=bI`Xf}>u=TUz9@rmPKZMkGdN4P<^Kzwo_gBf~m$xX{2RSfczzOf(ufF;@-bXZPujNku9{?oFpbG{Y+>6jie>T9{>ALij&|8M7!yZX9`->dX~-g0_) zIndDXd+OIeYQ#Rsf%DFL-Q)kMCqFFXU)DE7{&gs<+%Y!zZ+&k=F4( zJ(oE>b-sVGTela;wOO`t0%ezk|*<7S{Jc4oG;r`Fy1w|KrjA z&q4~!n>$xK|3}8Y$N$RbpFFPG>~DPqvG!F_f>mNPZIv4VgNkHNtbQ#AN|(b z^#9D6 zJIZ2!Ntx%*^Z6f#`9HQri{yG~_-Fo}EOUQA?@zw+tC_Md%8pvg=`oc7y?gKW_`mbc zH;{h`Q`NM|3jgME7+i!QSDEqaT(__m2fC0xn{)Y{7GY$atzgr2y_b{yRKV@hp z{J&3qeb;2s->;YZ4RV>&Qyf!}Ca1@gf5;zzaRA-AJxb?{X(BMrUsZU!UUwP!cgsJ2 z^)&o5{~sAUFcbcNVetJd(PmG(?jom$@2{s>PMgzX%0C>>VgMh0a9qiLn)yE_Ou+s> z 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index b65c38c07..565697fd4 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "owner": "Zagrios" }, "fileAssociations": [ - { "ext": "bplist", "description": "Beat Saber Playlist", "icon": "./assets/favicon.ico", "role": "Viewer" } + { "ext": "bplist", "description": "Beat Saber Playlist (BSManager)", "icon": "./assets/bsm_file.ico", "role": "Viewer" } ] }, "repository": { diff --git a/src/main/main.ts b/src/main/main.ts index 6cf82c74e..c3d69279c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,7 @@ import { BSLauncherService } from "./services/bs-launcher/bs-launcher.service"; import { IpcRequest } from "shared/models/ipc"; import { LivShortcut } from "./services/liv/liv-shortcut.service"; import { SteamLauncherService } from "./services/bs-launcher/steam-launcher.service"; +import { FileAssociationService } from "./services/file-association.service"; const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true"; @@ -69,19 +70,29 @@ const initServicesMustBeInitialized = () => { BSLauncherService.getInstance(); } +const findDeepLinkInArgs = (args: string[]): string => { + return args.find(arg => DeepLinkService.getInstance().isDeepLink(arg)); +} + +const findAssociatedFileInArgs = (args: string[]): string => { + return args.find(arg => FileAssociationService.getInstance().isFileAssociated(arg)); +} + const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on("second-instance", (_, argv) => { - const deepLink = argv.find(arg => DeepLinkService.getInstance().isDeepLink(arg)); + const deepLink = findDeepLinkInArgs(argv); + const associatedFile = findAssociatedFileInArgs(argv); - if (!deepLink) { - return; + if (deepLink) { + DeepLinkService.getInstance().dispatchLinkOpened(deepLink); + } else if (associatedFile) { + FileAssociationService.getInstance().handleFileAssociation(associatedFile); } - DeepLinkService.getInstance().dispatchLinkOpened(deepLink); }); app.on("window-all-closed", () => { @@ -90,25 +101,28 @@ if (!gotTheLock) { }) app.whenReady().then(() => { - + app.setAppUserModelId(APP_NAME); initServicesMustBeInitialized(); - - const deepLink = process.argv.find(arg => DeepLinkService.getInstance().isDeepLink(arg)); - if (!deepLink) { - createWindow(); - } else { + const deepLink = findDeepLinkInArgs(process.argv); + const associatedFile = findAssociatedFileInArgs(process.argv); + + if (deepLink) { DeepLinkService.getInstance().dispatchLinkOpened(deepLink); + } else if (associatedFile) { + FileAssociationService.getInstance().handleFileAssociation(associatedFile); + } else { + createWindow(); } - + SteamLauncherService.getInstance().restoreSteamVR(); - + // Log renderer errors ipcMain.on("log-error", (_, args: IpcRequest) => { log.error(args?.args); }); - + }).catch(log.error); } diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index 22881e543..a22d8e221 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -12,7 +12,7 @@ import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, copyFile, pathExists, realpath } from "fs-extra"; import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; -import { IpcService } from "../ipc.service"; +import { FileAssociationService } from "../file-association.service"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -33,18 +33,18 @@ export class LocalPlaylistsManagerService { private readonly maps: LocalMapsManagerService; private readonly request: RequestService; private readonly deepLink: DeepLinkService; + private readonly fileAssociation: FileAssociationService; private readonly windows: WindowManagerService; private readonly bsaver: BeatSaverService; - private readonly ipc: IpcService; private constructor() { this.maps = LocalMapsManagerService.getInstance(); this.versions = BSLocalVersionService.getInstance(); this.request = RequestService.getInstance(); this.deepLink = DeepLinkService.getInstance(); + this.fileAssociation = FileAssociationService.getInstance(); this.windows = WindowManagerService.getInstance(); this.bsaver = BeatSaverService.getInstance(); - this.ipc = IpcService.getInstance(); this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => { log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link); @@ -52,11 +52,16 @@ export class LocalPlaylistsManagerService { const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") : ""; this.openOneClickDownloadPlaylistWindow(bplistUrl); }); + + this.fileAssociation.registerFileAssociation(".bplist", filePath => { + log.info("FILE ASSOCIATION RECEIVED", filePath); + this.openOneClickDownloadPlaylistWindow(filePath); + }); } private async getPlaylistsFolder(version?: BSVersion) { if (!version) { - throw "Playlists are not available to be linked yet"; + throw new Error("Playlists are not available to be linked yet"); } const versionFolder = await this.versions.getVersionPath(version); @@ -79,14 +84,14 @@ export class LocalPlaylistsManagerService { } return lastValueFrom(this.request.downloadFile(bslistSource, destFile)).then(res => res.data); } - - private async readPlaylistFile(path: string): Promise { - if (!(await pathExist(path))) { - throw `bplist file not exist at ${path}`; + + private async readPlaylistFile(filePath: string): Promise { + if (!(await pathExist(filePath))) { + throw new Error(`bplist file not exist at ${filePath}`); } - const rawContent = readFileSync(path).toString(); + const rawContent = readFileSync(filePath).toString(); return JSON.parse(rawContent); } @@ -102,8 +107,8 @@ export class LocalPlaylistsManagerService { const bpListFilePath = await this.installBPListFile(bpListUrl, version); const bpList = await this.readPlaylistFile(bpListFilePath); - - const progress: Progression = { + + const progress: Progression = { total: bpList.songs.length, current: 0, data: { diff --git a/src/main/services/file-association.service.ts b/src/main/services/file-association.service.ts new file mode 100644 index 000000000..c43795e9b --- /dev/null +++ b/src/main/services/file-association.service.ts @@ -0,0 +1,56 @@ +import { pathExistsSync } from "fs-extra"; +import path from "path"; +import log from "electron-log"; + +export class FileAssociationService { + private static instance: FileAssociationService; + + public static getInstance(): FileAssociationService { + if (!FileAssociationService.instance) { + FileAssociationService.instance = new FileAssociationService(); + } + return FileAssociationService.instance; + } + + private readonly listeners = new Map(); + + private constructor() {} + + private getAbsolutePath(filePath: string) { + return path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + } + + public registerFileAssociation(ext: ExtKey, fn: Listerner) { + if (!this.listeners.has(ext)) { + this.listeners.set(ext, [] as Listerner[]); + } + + this.listeners.get(ext).push(fn); + } + + public isFileAssociated(filePath: string): boolean { + const fileExt = path.extname(filePath) as ExtKey; + return this.listeners.has(fileExt); + } + + public handleFileAssociation(filePath: string) { + const absolutePath = this.getAbsolutePath(filePath); + + if(!pathExistsSync(absolutePath)) { + log.error(`[FileAssociationService] File not found: ${absolutePath}`); + return; + } + + const fileExt = path.extname(absolutePath) as ExtKey; + const listeners = this.listeners.get(fileExt); + + if (!listeners) { + log.error(`[FileAssociationService] No listeners for file: ${absolutePath}`); + return; + } + + listeners.forEach(listener => listener(this.getAbsolutePath(filePath))); + } +} +type ExtKey = `.${string}`; +type Listerner = (filePath: string) => void; From 9a3ebf29718315be9fe38bd346fcf912f6bd1f08 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:36:25 +0100 Subject: [PATCH 03/36] [feature-107] moving files around and create Playlist panel component --- src/main/ipcs/bs-playlist-ipcs.ts | 28 +++---- .../maps-playlists-panel.component.tsx | 74 ++++++++----------- .../maps}/filter-panel.component.tsx | 10 +-- .../maps}/local-maps-list-panel.component.tsx | 26 ++++--- .../maps}/map-item.component.tsx | 14 ++-- .../maps}/maps-row.component.tsx | 0 .../local-playlists-list-panel.component.tsx | 15 ++++ .../download-maps-modal.component.tsx | 4 +- .../share-folders-modal.component.tsx | 4 +- .../bs-content-tab-item.component.tsx | 12 +-- .../link-button.component.tsx | 12 +-- src/renderer/hooks/use-change-once.hook.ts | 18 ----- .../hooks/use-change-until-equal.hook.ts | 35 +++++++++ .../pages/shared-contents-page.component.tsx | 2 +- .../pages/version-viewer.component.tsx | 2 +- src/renderer/services/maps-manager.service.ts | 8 +- .../services/playlists-manager.service.ts | 23 ++++-- 17 files changed, 157 insertions(+), 130 deletions(-) rename src/renderer/components/{maps-mangement-components => maps-playlists-panel}/maps-playlists-panel.component.tsx (72%) rename src/renderer/components/{maps-mangement-components => maps-playlists-panel/maps}/filter-panel.component.tsx (96%) rename src/renderer/components/{maps-mangement-components => maps-playlists-panel/maps}/local-maps-list-panel.component.tsx (93%) rename src/renderer/components/{maps-mangement-components => maps-playlists-panel/maps}/map-item.component.tsx (97%) rename src/renderer/components/{maps-mangement-components => maps-playlists-panel/maps}/maps-row.component.tsx (100%) create mode 100644 src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx rename src/renderer/components/{maps-mangement-components => shared}/link-button.component.tsx (89%) delete mode 100644 src/renderer/hooks/use-change-once.hook.ts create mode 100644 src/renderer/hooks/use-change-until-equal.hook.ts diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 0b58dd856..6a0b5f61d 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -1,8 +1,6 @@ -import { ipcMain } from "electron"; -import { IpcRequest } from "shared/models/ipc"; import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service"; -import { UtilsService } from "../services/utils.service"; import { IpcService } from "../services/ipc.service"; +import { of, throwError } from "rxjs"; const ipc = IpcService.getInstance(); @@ -11,38 +9,32 @@ ipc.on("one-click-install-playlist", (req, reply) => { reply(mapsManager.oneClickInstallPlaylist(req.args)); }); -ipcMain.on("register-playlists-deep-link", async (event, request: IpcRequest) => { +ipc.on("register-playlists-deep-link", (_, reply) => { const maps = LocalPlaylistsManagerService.getInstance(); - const utils = UtilsService.getInstance(); try { - const res = maps.enableDeepLinks(); - utils.ipcSend(request.responceChannel, { success: true, data: res }); + reply(of(maps.enableDeepLinks())); } catch (e) { - utils.ipcSend(request.responceChannel, { success: false }); + reply(throwError(() => e)); } }); -ipcMain.on("unregister-playlists-deep-link", async (event, request: IpcRequest) => { +ipc.on("unregister-playlists-deep-link", (_, reply) => { const maps = LocalPlaylistsManagerService.getInstance(); - const utils = UtilsService.getInstance(); try { - const res = maps.disableDeepLinks(); - utils.ipcSend(request.responceChannel, { success: true, data: res }); + reply(of(maps.disableDeepLinks())); } catch (e) { - utils.ipcSend(request.responceChannel, { success: false }); + reply(throwError(() => e)); } }); -ipcMain.on("is-playlists-deep-links-enabled", async (event, request: IpcRequest) => { +ipc.on("is-playlists-deep-links-enabled", (_, reply) => { const maps = LocalPlaylistsManagerService.getInstance(); - const utils = UtilsService.getInstance(); try { - const res = maps.isDeepLinksEnabled(); - utils.ipcSend(request.responceChannel, { success: true, data: res }); + reply(of(maps.isDeepLinksEnabled())); } catch (e) { - utils.ipcSend(request.responceChannel, { success: false }); + reply(throwError(() => e)); } }); diff --git a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx similarity index 72% rename from src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx rename to src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 3705f0554..afb367c6f 100644 --- a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -1,15 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { BSVersion } from "shared/bs-version.interface"; -import { LocalMapsListPanel } from "./local-maps-list-panel.component"; +import { LocalMapsListPanel } from "./maps/local-maps-list-panel.component"; import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component"; -import { FilterPanel } from "./filter-panel.component"; +import { FilterPanel } from "./maps/filter-panel.component"; import { MapFilter } from "shared/models/maps/beat-saver.model"; import { MapsManagerService } from "renderer/services/maps-manager.service"; import { MapsDownloaderService } from "renderer/services/maps-downloader.service"; -import { BsmImage } from "../shared/bsm-image.component"; -import wipGif from "../../../../assets/images/gifs/wip.gif"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionListener } from "renderer/services/version-folder-linker.service"; +import { FolderLinkState } from "renderer/services/version-folder-linker.service"; import { useService } from "renderer/hooks/use-service.hook"; import { BsContentTabPanel } from "../shared/bs-content-tab-panel/bs-content-tab-panel.component"; import { BsmButton } from "../shared/bsm-button.component"; @@ -17,6 +15,8 @@ import { MapIcon } from "../svgs/icons/map-icon.component"; import { PlaylistIcon } from "../svgs/icons/playlist-icon.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { of } from "rxjs"; +import { LocalPlaylistsListPanel } from "./playlists/local-playlists-list-panel.component"; +import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; type Props = { version?: BSVersion; @@ -24,46 +24,28 @@ type Props = { }; export function MapsPlaylistsPanel({ version, isActive }: Props) { - + const mapsService = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); - const linker = useService(VersionFolderLinkerService); + const playlistsService = useService(PlaylistsManagerService); + const t = useTranslation(); const [tabIndex, setTabIndex] = useState(0); + + const mapsRef = useRef(); const [mapFilter, setMapFilter] = useState({}); const [mapSearch, setMapSearch] = useState(""); - const [playlistSearch, setPlaylistSearch] = useState(""); - const [mapsLinked, setMapsLinked] = useState(false); - const mapsLinkedState = useObservable(() => version ? mapsService.$mapsFolderLinkState(version) : of(null), FolderLinkState.Unlinked, [version]); - const t = useTranslation(); - const mapsRef = useRef(); - - useEffect(() => { - if (!version) { - return; - } + const mapsLinkedState = useObservable(() => { + if(!version) return of(FolderLinkState.Unlinked); + return mapsService.$mapsFolderLinkState(version); + }, FolderLinkState.Unlinked, [version]); - loadMapIsLinked(); - - const onMapsLinked: VersionLinkerActionListener = action => { - if (!action.relativeFolder.includes(MapsManagerService.RELATIVE_MAPS_FOLDER)) { - return; - } - loadMapIsLinked(); - }; - - linker.onVersionFolderLinked(onMapsLinked); - linker.onVersionFolderUnlinked(onMapsLinked); - - return () => { - linker.removeVersionFolderLinkedListener(onMapsLinked); - linker.removeVersionFolderUnlinkedListener(onMapsLinked); - }; - }, [version, isActive]); + const [playlistSearch, setPlaylistSearch] = useState(""); + const playlistLinkedState = useObservable(() => { + if(!version) return of(FolderLinkState.Unlinked); + return playlistsService.$playlistsFolderLinkState(version); + }, FolderLinkState.Unlinked, [version]); - const loadMapIsLinked = () => { - mapsService.versionHaveMapsLinked(version).then(setMapsLinked); - }; const handleSearch = (value: string) => { if (tabIndex === 0) { @@ -77,9 +59,12 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { }; const handleMapsLinkClick = () => { - if (!mapsLinked) { + if(mapsLinkedState === FolderLinkState.Pending || mapsLinkedState === FolderLinkState.Processing){ return Promise.resolve(false); } + + if (mapsLinkedState === FolderLinkState.Unlinked) { return mapsService.linkVersion(version); } + return mapsService.unlinkVersion(version); }; @@ -130,15 +115,16 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { text: "misc.playlists", icon: PlaylistIcon, onClick: () => setTabIndex(1), + linkProps: version ? { + state: playlistLinkedState, + onClick: () => Promise.resolve(false), + } : null, }, ]} > <> - -
- - Coming soon -
+ + diff --git a/src/renderer/components/maps-mangement-components/filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx similarity index 96% rename from src/renderer/components/maps-mangement-components/filter-panel.component.tsx rename to src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx index 7962b23e5..7c09a4d6f 100644 --- a/src/renderer/components/maps-mangement-components/filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx @@ -3,18 +3,18 @@ import { motion } from "framer-motion"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { MAP_TYPES } from "renderer/partials/maps/map-tags/map-types"; import { MAP_STYLES } from "renderer/partials/maps/map-tags/map-styles"; -import { BsmCheckbox } from "../shared/bsm-checkbox.component"; -import { minToS } from "../../../shared/helpers/time.helpers"; +import { BsmCheckbox } from "../../shared/bsm-checkbox.component"; +import { minToS } from "../../../../shared/helpers/time.helpers"; import dateFormat from "dateformat"; -import { BsmRange } from "../shared/bsm-range.component"; +import { BsmRange } from "../../shared/bsm-range.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { MAP_SPECIFICITIES } from "renderer/partials/maps/map-general/map-specificity"; import { MAP_REQUIREMENTS } from "renderer/partials/maps/map-requirements/map-requirements"; import { MAP_DIFFICULTIES_COLORS } from "renderer/partials/maps/map-difficulties/map-difficulties-colors"; -import { BsmButton } from "../shared/bsm-button.component"; +import { BsmButton } from "../../shared/bsm-button.component"; import equal from "fast-deep-equal/es6"; import clone from "rfdc"; -import { GlowEffect } from "../shared/glow-effect.component"; +import { GlowEffect } from "../../shared/glow-effect.component"; export type Props = { className?: string; diff --git a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx similarity index 93% rename from src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx rename to src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index a6f66cf93..24243e05b 100644 --- a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -11,24 +11,26 @@ import { debounceTime, last, mergeMap, tap } from "rxjs/operators"; import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service"; import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -import BeatWaitingImg from "../../../../assets/images/apngs/beat-waiting.png"; -import BeatConflict from "../../../../assets/images/apngs/beat-conflict.png"; -import { BsmImage } from "../shared/bsm-image.component"; -import { BsmButton } from "../shared/bsm-button.component"; -import TextProgressBar from "../progress-bar/text-progress-bar.component"; -import { useChangeOnce } from "renderer/hooks/use-change-once.hook"; +import BeatWaitingImg from "../../../../../assets/images/apngs/beat-waiting.png"; +import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; +import { BsmImage } from "../../shared/bsm-image.component"; +import { BsmButton } from "../../shared/bsm-button.component"; +import TextProgressBar from "../../progress-bar/text-progress-bar.component"; +import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; import { useService } from "renderer/hooks/use-service.hook"; +import { FolderLinkState } from "renderer/services/version-folder-linker.service"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; type Props = { version: BSVersion; className?: string; filter?: MapFilter; search?: string; - linked?: boolean; + linkedState?: FolderLinkState; isActive?: boolean; }; -export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linked, isActive }: Props, forwardRef) => { +export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }: Props, forwardRef) => { const mapsManager = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); const bsaver = useService(BeatSaverService); @@ -41,7 +43,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear const [selectedMaps$] = useState(new BehaviorSubject([])); const [itemPerRow, setItemPerRow] = useState(2); const [listHeight, setListHeight] = useState(0); - const isActiveOnce = useChangeOnce(isActive, true); + const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true, emitOnEqual: true }); + const [linked, setLinked] = useState(false); const [loadPercent$] = useState(new BehaviorSubject(0)); @@ -68,6 +71,11 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear [selectedMaps$.value, maps, version] ); + useOnUpdate(() => { + if(linkedState === FolderLinkState.Pending || linkedState === FolderLinkState.Processing) return () => {}; + setLinked(linkedState === FolderLinkState.Linked); + }, [linkedState]); + useEffect(() => { if (isActiveOnce) { loadMaps(); diff --git a/src/renderer/components/maps-mangement-components/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx similarity index 97% rename from src/renderer/components/maps-mangement-components/map-item.component.tsx rename to src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 617776d7a..f2ef0b2c3 100644 --- a/src/renderer/components/maps-mangement-components/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -1,9 +1,9 @@ -import { BsmImage } from "../shared/bsm-image.component"; +import { BsmImage } from "../../shared/bsm-image.component"; import { BsvMapCharacteristic, BsvMapDifficultyType } from "shared/models/maps/beat-saver.model"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; -import { BsmLink } from "../shared/bsm-link.component"; -import { BsmIcon } from "../svgs/bsm-icon.component"; -import { BsmButton } from "../shared/bsm-button.component"; +import { BsmLink } from "../../shared/bsm-link.component"; +import { BsmIcon } from "../../svgs/bsm-icon.component"; +import { BsmButton } from "../../shared/bsm-button.component"; import { AnimatePresence, motion } from "framer-motion"; import { useState, Fragment, memo, useRef } from "react"; import { LinkOpenerService } from "renderer/services/link-opener.service"; @@ -12,13 +12,13 @@ import { AudioPlayerService } from "renderer/services/audio-player.service"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { map } from "rxjs/operators"; import equal from "fast-deep-equal/es6"; -import { BsmBasicSpinner } from "../shared/bsm-basic-spinner/bsm-basic-spinner.component"; -import defaultImage from "../../../../assets/images/default-version-img.jpg"; +import { BsmBasicSpinner } from "../../shared/bsm-basic-spinner/bsm-basic-spinner.component"; +import defaultImage from "../../../../../assets/images/default-version-img.jpg"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { MAP_DIFFICULTIES } from "renderer/partials/maps/map-difficulties/map-difficulties"; import { MAP_DIFFICULTIES_COLORS } from "renderer/partials/maps/map-difficulties/map-difficulties-colors"; import useDoubleClick from "use-double-click"; -import { GlowEffect } from "../shared/glow-effect.component"; +import { GlowEffect } from "../../shared/glow-effect.component"; import { useDelayedState } from "renderer/hooks/use-delayed-state.hook"; import { useService } from "renderer/hooks/use-service.hook"; import Tippy from "@tippyjs/react"; diff --git a/src/renderer/components/maps-mangement-components/maps-row.component.tsx b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx similarity index 100% rename from src/renderer/components/maps-mangement-components/maps-row.component.tsx rename to src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx new file mode 100644 index 000000000..dce4c93d0 --- /dev/null +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -0,0 +1,15 @@ +import { FolderLinkState } from "renderer/services/version-folder-linker.service"; +import { BSVersion } from "shared/bs-version.interface"; + +type Props = { + version: BSVersion; + className?: string; + linkedState?: FolderLinkState; + isActive?: boolean; +}; + +export function LocalPlaylistsListPanel({ version, className, isActive, linkedState }: Props) { + return ( +
local-playlists-list-panel.component
+ ) +} diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 04fa513bf..389a889ef 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -1,7 +1,7 @@ import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; -import { FilterPanel } from "renderer/components/maps-mangement-components/filter-panel.component"; -import { MapItem, ParsedMapDiff } from "renderer/components/maps-mangement-components/map-item.component"; +import { FilterPanel } from "renderer/components/maps-playlists-panel/maps/filter-panel.component"; +import { MapItem, ParsedMapDiff } from "renderer/components/maps-playlists-panel/maps/map-item.component"; import { BsmButton } from "renderer/components/shared/bsm-button.component"; import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component"; import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component"; diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx index c04865d7b..09e319a53 100644 --- a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -1,6 +1,6 @@ import Tippy from "@tippyjs/react"; import { useEffect, useState } from "react"; -import { LinkButton } from "renderer/components/maps-mangement-components/link-button.component"; +import { LinkButton } from "renderer/components/shared/link-button.component"; import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bsm-basic-spinner.component"; import { BsmButton } from "renderer/components/shared/bsm-button.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; @@ -137,7 +137,7 @@ const FolderItem = ({ version, relativeFolder, onDelete }: FolderProps) => {
-
)} ); -} \ No newline at end of file +} diff --git a/src/renderer/components/maps-mangement-components/link-button.component.tsx b/src/renderer/components/shared/link-button.component.tsx similarity index 89% rename from src/renderer/components/maps-mangement-components/link-button.component.tsx rename to src/renderer/components/shared/link-button.component.tsx index 9526fceba..3f8446d7a 100644 --- a/src/renderer/components/maps-mangement-components/link-button.component.tsx +++ b/src/renderer/components/shared/link-button.component.tsx @@ -3,6 +3,7 @@ import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { BsmIcon } from "../svgs/bsm-icon.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; +import React from "react"; export type LinkBtnProps = { className?: string; @@ -11,9 +12,9 @@ export type LinkBtnProps = { onClick?: () => unknown; }; -export const LinkButton = ({className, title, state, onClick}: LinkBtnProps) => { +export function LinkButton({className, title, state, onClick}: LinkBtnProps) { const t = useTranslation(); - + const color = useThemeColor("first-color"); const disabled = state === FolderLinkState.Processing || state === FolderLinkState.Pending; @@ -34,14 +35,15 @@ export const LinkButton = ({className, title, state, onClick}: LinkBtnProps) => const handleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - !disabled && onClick?.(); + if(disabled){ return; } + onClick?.(); } return ( (initialValue: T, onlyFirstIfTruthy?: boolean): T { - const [trackedValue, setTrackedValue] = useState(initialValue); - const didChangeOnceRef = useRef(onlyFirstIfTruthy ? !!initialValue : false); - - useEffect(() => { - if (didChangeOnceRef.current || equal(initialValue, trackedValue)) { - return; - } - - setTrackedValue(() => initialValue); - didChangeOnceRef.current = true; - }, [initialValue]); - - return trackedValue; -} diff --git a/src/renderer/hooks/use-change-until-equal.hook.ts b/src/renderer/hooks/use-change-until-equal.hook.ts new file mode 100644 index 000000000..3d1b35df1 --- /dev/null +++ b/src/renderer/hooks/use-change-until-equal.hook.ts @@ -0,0 +1,35 @@ +import equal from "fast-deep-equal"; +import { useEffect, useRef, useState } from "react"; + +type Options = { + untilEqual: T; + emitOnEqual?: boolean; +} + +export function useChangeUntilEqual(variableValue: T, { untilEqual, emitOnEqual }: Options): T { + const [value, setValue] = useState(variableValue); + + const untilEqualRef = useRef(untilEqual); + const emitOnEqualRef = useRef(emitOnEqual); + const hasBeenEqual = useRef(false); + + useEffect(() => { + if(hasBeenEqual.current) { + return; + } + + const isEqual = equal(variableValue, untilEqualRef.current); + + if(!isEqual){ + return setValue(variableValue); + } + + hasBeenEqual.current = true; + + if(emitOnEqualRef.current) { + setValue(variableValue); + } + }, [variableValue]); + + return value; +} diff --git a/src/renderer/pages/shared-contents-page.component.tsx b/src/renderer/pages/shared-contents-page.component.tsx index 78dff7738..cc72844cd 100644 --- a/src/renderer/pages/shared-contents-page.component.tsx +++ b/src/renderer/pages/shared-contents-page.component.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { MapsPlaylistsPanel } from "renderer/components/maps-mangement-components/maps-playlists-panel.component"; +import { MapsPlaylistsPanel } from "renderer/components/maps-playlists-panel/maps-playlists-panel.component"; import { ModelsPanel } from "renderer/components/models-management/models-panel.component"; import { TabNavBar } from "renderer/components/shared/tab-nav-bar.component"; import { Slideshow } from "renderer/components/slideshow/slideshow.component"; diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index fdefa9f06..3e36b3b7c 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -12,7 +12,7 @@ import { IpcService } from "renderer/services/ipc.service"; import { LaunchSlide } from "renderer/components/version-viewer/slides/launch/launch-slide.component"; import { ModsSlide } from "renderer/components/version-viewer/slides/mods/mods-slide.component"; import { UninstallModal } from "renderer/components/modal/modal-types/uninstall-modal.component"; -import { MapsPlaylistsPanel } from "renderer/components/maps-mangement-components/maps-playlists-panel.component"; +import { MapsPlaylistsPanel } from "renderer/components/maps-playlists-panel/maps-playlists-panel.component"; import { ShareFoldersModal } from "renderer/components/modal/modal-types/share-folders-modal.component"; import { ModelsPanel } from "renderer/components/models-management/models-panel.component"; import { useService } from "renderer/hooks/use-service.hook"; diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 4c2270745..e973b8595 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -101,7 +101,9 @@ export class MapsManagerService { const progress$ = this.ipcService.sendV2("delete-maps", { args: maps }).pipe(map(progress => (progress.deleted / progress.total) * 100)); - showProgressBar && this.progressBar.show(progress$, true); + if (showProgressBar) { + this.progressBar.show(progress$, true); + } progress$.toPromise().finally(() => this.progressBar.hide(true)); @@ -175,10 +177,6 @@ export class MapsManagerService { return this.lastUnlinkedVersion$.asObservable(); } - public $mapsLinkingPending(version: BSVersion): Observable { - return this.linker.$isPending(version, MapsManagerService.RELATIVE_MAPS_FOLDER); - } - public $mapsFolderLinkState(version: BSVersion): Observable { return this.linker.$folderLinkedState(version, MapsManagerService.RELATIVE_MAPS_FOLDER); } diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index 5c81b6026..707f49d0a 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -1,4 +1,7 @@ +import { BSVersion } from "shared/bs-version.interface"; import { IpcService } from "./ipc.service"; +import { Observable, lastValueFrom } from "rxjs"; +import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; export class PlaylistsManagerService { private static instance: PlaylistsManagerService; @@ -10,23 +13,29 @@ export class PlaylistsManagerService { return PlaylistsManagerService.instance; } + public static readonly RELATIVE_PLAYLISTS_FOLDER = "Playlists"; + private readonly ipc: IpcService; + private readonly linker: VersionFolderLinkerService; private constructor() { this.ipc = IpcService.getInstance(); + this.linker = VersionFolderLinkerService.getInstance(); } public isDeepLinksEnabled(): Promise { - return this.ipc.send("is-playlists-deep-links-enabled").then(res => (res.success ? res.data : false)); + return lastValueFrom(this.ipc.sendV2("is-playlists-deep-links-enabled")); + } + + public enableDeepLink(): Promise { + return lastValueFrom(this.ipc.sendV2("register-playlists-deep-link")); } - public async enableDeepLink(): Promise { - const res = await this.ipc.send("register-playlists-deep-link"); - return res.success ? res.data : false; + public disableDeepLink(): Promise { + return lastValueFrom(this.ipc.sendV2("unregister-playlists-deep-link")); } - public async disableDeepLink(): Promise { - const res = await this.ipc.send("unregister-playlists-deep-link"); - return res.success ? res.data : false; + public $playlistsFolderLinkState(version: BSVersion): Observable { + return this.linker.$folderLinkedState(version, PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER); } } From b42231b134ad5305c5c581eba69e90263b704a40 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Sun, 18 Feb 2024 22:24:57 +0100 Subject: [PATCH 04/36] [feature-107] lot of changes but mainly, maps now use a cache and load almost instantly after first load --- assets/jsons/bs-versions.json | 9 + assets/jsons/translations/de.json | 6 +- assets/jsons/translations/en.json | 6 +- assets/jsons/translations/es.json | 6 +- assets/jsons/translations/fr.json | 6 +- assets/jsons/translations/ja.json | 6 +- assets/jsons/translations/ru.json | 6 +- assets/jsons/translations/zh-tw.json | 6 +- assets/jsons/translations/zh.json | 6 +- assets/protos/song_details_cache_v1.proto | 65 ++ package-lock.json | 594 +++++++++++++++--- package.json | 10 +- src/main/constants.ts | 8 +- src/main/helpers/fs.helpers.ts | 5 +- src/main/ipcs/bs-maps-ipcs.ts | 2 +- src/main/ipcs/bs-playlist-ipcs.ts | 21 +- src/main/ipcs/bs-version-ipcs.ts | 2 +- src/main/main.ts | 6 +- src/main/models/json-cache.class.ts | 77 +++ .../local-playlists-manager.service.ts | 35 +- .../{ => maps}/local-maps-manager.service.ts | 74 ++- .../maps/song-cache.service.ts | 42 ++ .../maps/song-details-cache.service.ts | 167 +++++ src/main/services/bs-local-version.service.ts | 20 +- .../bs-steam-downloader.service.ts | 4 +- src/main/services/configuration.service.ts | 3 +- .../services/installation-location.service.ts | 26 +- src/main/services/ipc.service.ts | 4 +- src/main/services/request.service.ts | 51 +- .../services/version-folder-linker.service.ts | 2 +- .../maps/local-maps-list-panel.component.tsx | 97 +-- .../maps/map-item.component.tsx | 11 +- .../maps/maps-row.component.tsx | 48 +- .../local-playlists-list-panel.component.tsx | 14 + .../hooks/use-change-until-equal.hook.ts | 13 +- .../partials/maps/map-tags/map-styles.ts | 2 +- .../services/playlists-manager.service.ts | 4 + src/shared/helpers/function.helpers.ts | 2 + src/shared/models/maps/beat-saver.model.ts | 2 +- .../models/maps/bsm-local-map.interface.ts | 4 +- src/shared/models/maps/index.ts | 1 + src/shared/models/maps/raw-map.model.ts | 6 +- .../models/maps/song-details-cache.model.ts | 82 +++ 43 files changed, 1256 insertions(+), 305 deletions(-) create mode 100644 assets/protos/song_details_cache_v1.proto create mode 100644 src/main/models/json-cache.class.ts rename src/main/services/additional-content/{ => maps}/local-maps-manager.service.ts (82%) create mode 100644 src/main/services/additional-content/maps/song-cache.service.ts create mode 100644 src/main/services/additional-content/maps/song-details-cache.service.ts create mode 100644 src/shared/models/maps/song-details-cache.model.ts diff --git a/assets/jsons/bs-versions.json b/assets/jsons/bs-versions.json index 6130defa0..31d15741a 100644 --- a/assets/jsons/bs-versions.json +++ b/assets/jsons/bs-versions.json @@ -681,5 +681,14 @@ "ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/ae11d195bbe41a0f244d7b4d055a0ab89f88e5bb.png", "ReleaseDate": "1702309893", "year": "2023" + }, + { + "BSVersion": "1.34.5", + "BSManifest": "7413405154512822201", + "OculusBinaryId": "6639053626194360", + "ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4036983137766828694", + "ReleaseImg": "https://clan.cloudflare.steamstatic.com/images/32055887/925e60f14c6815f7617fde11a894ed20f54a0846.png", + "ReleaseDate": "1707745958", + "year": "2024" } ] \ No newline at end of file diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index f777c60c1..47911e4aa 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -763,13 +763,13 @@ "rb": "R&B", "holiday": "Urlaub", "vocaloid": "Vokaloid", - "jrock": "J-Rock", + "j-rock": "J-Rock", "trance": "Trance", "drumbass": "Basstrommel", "comedy": "Komödie", "instrumental": "Instrumental", "hardcore": "Hardcore", - "kpop": "K-Pop", + "k-pop": "K-Pop", "indie": "Indie", "techno": "Techno", "house": "House", @@ -780,7 +780,7 @@ "metal": "Metal", "anime": "Anime", "hiphop": "HipHop", - "jpop": "J-Pop", + "j-pop": "J-Pop", "rock": "Rock", "pop": "Pop", "electronic": "Elektronisch", diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 75f6b5510..d916d46d3 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -763,13 +763,13 @@ "rb": "r&b", "holiday": "holiday", "vocaloid": "vocaloid", - "jrock": "j-rock", + "j-rock": "j-rock", "trance": "trance", "drumbass": "drum & bass", "comedy": "comedy", "instrumental": "instrumental", "hardcore": "hardcore", - "kpop": "k-pop", + "k-pop": "k-pop", "indie": "indie", "techno": "techno", "house": "house", @@ -780,7 +780,7 @@ "metal": "metal", "anime": "anime", "hiphop": "hiphop", - "jpop": "j-pop", + "j-pop": "j-pop", "rock": "rock", "pop": "pop", "electronic": "electronic", diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index 27c34ef60..6e7948cf8 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -763,13 +763,13 @@ "rb": "r&b", "holiday": "vacaciones", "vocaloid": "vocaloid", - "jrock": "j-rock", + "j-rock": "j-rock", "trance": "trance", "drumbass": "drum & bass", "comedy": "comedia", "instrumental": "instrumental", "hardcore": "hardcore", - "kpop": "k-pop", + "k-pop": "k-pop", "indie": "indie", "techno": "tecno", "house": "house", @@ -780,7 +780,7 @@ "metal": "metal", "anime": "anime", "hiphop": "hiphop", - "jpop": "j-pop", + "j-pop": "j-pop", "rock": "rock", "pop": "pop", "electronic": "electrónico", diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index e50b6212e..b0e0f3e6d 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -763,13 +763,13 @@ "rb": "r&b", "holiday": "vacance", "vocaloid": "vocaloid", - "jrock": "j-rock", + "j-rock": "j-rock", "trance": "trance", "drumbass": "drum & bass", "comedy": "comédie", "instrumental": "instrumental", "hardcore": "hardcore", - "kpop": "k-pop", + "k-pop": "k-pop", "indie": "indé", "techno": "techno", "house": "house", @@ -780,7 +780,7 @@ "metal": "metal", "anime": "anime", "hiphop": "hiphop", - "jpop": "j-pop", + "j-pop": "j-pop", "rock": "rock", "pop": "pop", "electronic": "électronique", diff --git a/assets/jsons/translations/ja.json b/assets/jsons/translations/ja.json index 58caf2188..04766a8d4 100644 --- a/assets/jsons/translations/ja.json +++ b/assets/jsons/translations/ja.json @@ -763,13 +763,13 @@ "rb": "r&b", "holiday": "休日", "vocaloid": "ボーカロイド", - "jrock": "j-rock", + "j-rock": "j-rock", "trance": "トランス", "drumbass": "ドラムンベース", "comedy": "コメディ", "instrumental": "器楽", "hardcore": "ハードコア", - "kpop": "k-pop", + "k-pop": "k-pop", "indie": "インディー", "techno": "テクノ", "house": "家", @@ -780,7 +780,7 @@ "metal": "メタル", "anime": "アニメ", "hiphop": "ヒップポップ", - "jpop": "j-pop", + "j-pop": "j-pop", "rock": "ロック", "pop": "ポップ", "electronic": "エレクトロニック", diff --git a/assets/jsons/translations/ru.json b/assets/jsons/translations/ru.json index 97169f796..34321568b 100644 --- a/assets/jsons/translations/ru.json +++ b/assets/jsons/translations/ru.json @@ -762,13 +762,13 @@ "rb": "ар-н-би", "holiday": "праздник", "vocaloid": "вокалоиды", - "jrock": "джей-рок", + "j-rock": "джей-рок", "trance": "транс", "drumbass": "драм-н-бейс", "comedy": "комедия", "instrumental": "инструментальные", "hardcore": "хардкор", - "kpop": "кей-поп", + "k-pop": "кей-поп", "indie": "инди", "techno": "техно", "house": "хаус", @@ -779,7 +779,7 @@ "metal": "метал", "anime": "аниме", "hiphop": "хипхоп", - "jpop": "джей-поп", + "j-pop": "джей-поп", "rock": "прк", "pop": "поп", "electronic": "электроника", diff --git a/assets/jsons/translations/zh-tw.json b/assets/jsons/translations/zh-tw.json index ea89a07f0..06a6d8cca 100644 --- a/assets/jsons/translations/zh-tw.json +++ b/assets/jsons/translations/zh-tw.json @@ -763,13 +763,13 @@ "rb": "R&B", "holiday": "Holiday", "vocaloid": "Vocaloid", - "jrock": "J-rock", + "j-rock": "J-rock", "trance": "Trance", "drumbass": "Drum & Bass", "comedy": "Comedy", "instrumental": "Instrumental", "hardcore": "Hardcore", - "kpop": "K-pop", + "k-pop": "K-pop", "indie": "Indie", "techno": "Techno", "house": "House", @@ -780,7 +780,7 @@ "metal": "Metal", "anime": "Anime", "hiphop": "Hiphop", - "jpop": "J-pop", + "j-pop": "J-pop", "rock": "Rock", "pop": "Pop", "electronic": "Electronic", diff --git a/assets/jsons/translations/zh.json b/assets/jsons/translations/zh.json index 4efcef6b4..ce678890b 100644 --- a/assets/jsons/translations/zh.json +++ b/assets/jsons/translations/zh.json @@ -763,13 +763,13 @@ "rb": "r&b", "holiday": "holiday", "vocaloid": "vocaloid", - "jrock": "j-rock", + "j-rock": "j-rock", "trance": "trance", "drumbass": "drum & bass", "comedy": "comedy", "instrumental": "instrumental", "hardcore": "hardcore", - "kpop": "k-pop", + "k-pop": "k-pop", "indie": "indie", "techno": "techno", "house": "house", @@ -780,7 +780,7 @@ "metal": "metal", "anime": "anime", "hiphop": "hiphop", - "jpop": "j-pop", + "j-pop": "j-pop", "rock": "rock", "pop": "pop", "electronic": "electronic", diff --git a/assets/protos/song_details_cache_v1.proto b/assets/protos/song_details_cache_v1.proto new file mode 100644 index 000000000..a312b3fe6 --- /dev/null +++ b/assets/protos/song_details_cache_v1.proto @@ -0,0 +1,65 @@ +syntax = "proto3"; + +package song_details_cache_v1; + +message SongDetailsCache { + repeated SongDetails songs = 1; + int64 lastUpdated = 2; + uint32 total = 3; +} + +message SongDetails { + string id = 1; + string hash = 2; + string name = 3; + MapDetailMetadata metadata = 4; + Uploader uploader = 5; + int32 uploadedAt = 6; + repeated string tags = 7; + float bpm = 8; + bool ranked = 9; + bool qualified = 10; + bool curated = 11; + bool rankedBL = 12; + bool nominatedBL = 13; + bool qualifiedBL = 14; + int32 upVotes = 15; + int32 downVotes = 16; + int32 downloads = 17; + int32 duration = 18; + bool automapper = 19; + repeated Difficulty difficulties = 20; +} + +message Difficulty { + string difficulty = 1; + string characteristic = 2; + string label = 3; + float stars = 4; + float starsBL = 5; + float njs = 6; + float nps = 7; + float offset = 8; + bool chroma = 9; + bool cinema = 10; + bool me = 11; + bool ne = 12; + int32 bombs = 13; + int32 notes = 14; + int32 obstacles = 15; +} + +message Uploader { + string name = 1; + int32 id = 2; + bool verified = 3; +} + +message MapDetailMetadata { + float bpm = 1; + int32 duration = 2; + string levelAuthorName = 3; + string songAuthorName = 4; + string songName = 5; + string songSubName = 6; +} diff --git a/package-lock.json b/package-lock.json index f33dd6801..655240b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "fast-deep-equal": "^3.1.3", "framer-motion": "^11.0.3", "fs-extra": "^11.2.0", + "got": "^14.2.0", "history": "^5.3.0", "is-elevated": "^4.0.0", "jszip": "^3.10.1", @@ -30,6 +31,7 @@ "node-fetch": "^3.3.2", "node-stream-zip": "^1.15.0", "pako": "^2.1.0", + "protobufjs": "^7.2.6", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-colorful": "^5.6.1", @@ -64,6 +66,7 @@ "@types/color": "^3.0.3", "@types/crypto-js": "^4.2.1", "@types/dateformat": "^5.0.0", + "@types/got": "^9.6.12", "@types/jest": "^29.5.11", "@types/node": "20.11.15", "@types/node-fetch": "^2.6.3", @@ -925,6 +928,57 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@electron/get/node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get/node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/@electron/get/node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/get/node_modules/fs-extra": { "version": "8.1.0", "dev": true, @@ -938,6 +992,44 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@electron/get/node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/@electron/get/node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/@electron/get/node_modules/jsonfile": { "version": "4.0.0", "dev": true, @@ -946,6 +1038,48 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/@electron/get/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/get/node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/get/node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@electron/get/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1061,6 +1195,57 @@ "node": ">=12.13.0" } }, + "node_modules/@electron/rebuild/node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@electron/rebuild/node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/@electron/rebuild/node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/rebuild/node_modules/fs-extra": { "version": "10.1.0", "dev": true, @@ -1074,6 +1259,86 @@ "node": ">=12" } }, + "node_modules/@electron/rebuild/node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/@electron/rebuild/node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/@electron/rebuild/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/rebuild/node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@electron/universal": { "version": "1.4.1", "dev": true, @@ -2088,6 +2353,60 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@remix-run/router": { "version": "1.14.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", @@ -2102,11 +2421,11 @@ "license": "MIT" }, "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "dev": true, - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-6.1.0.tgz", + "integrity": "sha512-BuvU07zq3tQ/2SIgBsEuxKYDyDjC0n7Zir52bpHy2xnBbW81+po43aLFPLbeV3HRAheFbGud1qgcqSYfhtHMAg==", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sindresorhus/is?sponsor=1" @@ -2129,14 +2448,14 @@ } }, "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "dev": true, - "license": "MIT", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", "dependencies": { - "defer-to-connect": "^2.0.0" + "defer-to-connect": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=14.16" } }, "node_modules/@teamsupercell/typings-for-css-modules-loader": { @@ -2409,8 +2728,9 @@ }, "node_modules/@types/cacheable-request": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", "dev": true, - "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", @@ -2526,6 +2846,31 @@ "@types/node": "*" } }, + "node_modules/@types/got": { + "version": "9.6.12", + "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.12.tgz", + "integrity": "sha512-X4pj/HGHbXVLqTpKjA2ahI4rV/nNBc9mGO2I/0CgAra+F2dKgMXnENv2SRpemScBzBAI4vMelIVYViQxlSE6xA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/got/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "dev": true, @@ -2540,9 +2885,9 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.1", - "dev": true, - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" }, "node_modules/@types/http-proxy": { "version": "1.17.9", @@ -2623,8 +2968,9 @@ }, "node_modules/@types/keyv": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*" } @@ -2641,7 +2987,6 @@ }, "node_modules/@types/node": { "version": "20.11.15", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -2761,9 +3106,10 @@ } }, "node_modules/@types/responselike": { - "version": "1.0.0", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*" } @@ -2829,6 +3175,12 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/use-double-click": { "version": "1.0.4", "dev": true, @@ -4893,28 +5245,39 @@ } }, "node_modules/cacheable-lookup": { - "version": "5.0.4", - "dev": true, - "license": "MIT", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", "engines": { - "node": ">=10.6.0" + "node": ">=14.16" } }, "node_modules/cacheable-request": { - "version": "7.0.2", - "dev": true, - "license": "MIT", + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.16" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/call-bind": { @@ -5224,8 +5587,9 @@ }, "node_modules/clone-response": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", "dev": true, - "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" }, @@ -5233,6 +5597,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/co": { "version": "4.6.0", "dev": true, @@ -6125,7 +6498,6 @@ }, "node_modules/decompress-response": { "version": "6.0.0", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -6139,7 +6511,6 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6228,8 +6599,8 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "engines": { "node": ">=10" } @@ -8663,6 +9034,14 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz", + "integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==", + "engines": { + "node": ">= 18" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -9059,29 +9438,40 @@ } }, "node_modules/got": { - "version": "11.8.5", - "dev": true, - "license": "MIT", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/got/-/got-14.2.0.tgz", + "integrity": "sha512-dBq2KkHcQl3AwPoIWsLsQScCPpUgRulz1qZVthjPYKYOPmYfBnekR3vxecjZbm91Vc3JUGnV9mqFX7B+Fe2quw==", "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", + "@sindresorhus/is": "^6.1.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.14", "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" + "form-data-encoder": "^4.0.2", + "get-stream": "^8.0.1", + "http2-wrapper": "^2.2.1", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^3.0.0" }, "engines": { - "node": ">=10.19.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/got?sponsor=1" } }, + "node_modules/got/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "license": "ISC" @@ -9388,7 +9778,6 @@ }, "node_modules/http-cache-semantics": { "version": "4.1.1", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-deceiver": { @@ -9487,12 +9876,12 @@ } }, "node_modules/http2-wrapper": { - "version": "1.0.3", - "dev": true, - "license": "MIT", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", "dependencies": { "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" + "resolve-alpn": "^1.2.0" }, "engines": { "node": ">=10.19.0" @@ -11399,8 +11788,8 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -11535,9 +11924,9 @@ "license": "MIT" }, "node_modules/keyv": { - "version": "4.5.2", - "dev": true, - "license": "MIT", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": { "json-buffer": "3.0.1" } @@ -12216,6 +12605,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -12235,11 +12629,14 @@ } }, "node_modules/lowercase-keys": { - "version": "2.0.0", - "dev": true, - "license": "MIT", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lru-cache": { @@ -12459,11 +12856,14 @@ } }, "node_modules/mimic-response": { - "version": "1.0.1", - "dev": true, - "license": "MIT", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", "engines": { - "node": ">=4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/min-document": { @@ -12908,11 +13308,11 @@ } }, "node_modules/normalize-url": { - "version": "6.1.0", - "dev": true, - "license": "MIT", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", + "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13212,11 +13612,11 @@ } }, "node_modules/p-cancelable": { - "version": "2.1.1", - "dev": true, - "license": "MIT", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", "engines": { - "node": ">=8" + "node": ">=14.16" } }, "node_modules/p-limit": { @@ -14346,6 +14746,29 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.6.tgz", + "integrity": "sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "dev": true, @@ -14577,7 +15000,6 @@ }, "node_modules/quick-lru": { "version": "5.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -15071,8 +15493,8 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, "node_modules/resolve-cwd": { "version": "3.0.0", @@ -15110,11 +15532,14 @@ } }, "node_modules/responselike": { - "version": "2.0.1", - "dev": true, - "license": "MIT", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", "dependencies": { - "lowercase-keys": "^2.0.0" + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17162,7 +17587,6 @@ }, "node_modules/undici-types": { "version": "5.26.5", - "devOptional": true, "license": "MIT" }, "node_modules/unique-filename": { diff --git a/package.json b/package.json index 565697fd4..ac84ad1bd 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,12 @@ "owner": "Zagrios" }, "fileAssociations": [ - { "ext": "bplist", "description": "Beat Saber Playlist (BSManager)", "icon": "./assets/bsm_file.ico", "role": "Viewer" } + { + "ext": "bplist", + "description": "Beat Saber Playlist (BSManager)", + "icon": "./assets/bsm_file.ico", + "role": "Viewer" + } ] }, "repository": { @@ -164,6 +169,7 @@ "@types/color": "^3.0.3", "@types/crypto-js": "^4.2.1", "@types/dateformat": "^5.0.0", + "@types/got": "^9.6.12", "@types/jest": "^29.5.11", "@types/node": "20.11.15", "@types/node-fetch": "^2.6.3", @@ -254,6 +260,7 @@ "fast-deep-equal": "^3.1.3", "framer-motion": "^11.0.3", "fs-extra": "^11.2.0", + "got": "^14.2.0", "history": "^5.3.0", "is-elevated": "^4.0.0", "jszip": "^3.10.1", @@ -262,6 +269,7 @@ "node-fetch": "^3.3.2", "node-stream-zip": "^1.15.0", "pako": "^2.1.0", + "protobufjs": "^7.2.6", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-colorful": "^5.6.1", diff --git a/src/main/constants.ts b/src/main/constants.ts index 5297a03bf..3a3ba13f4 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import { constants } from "http2"; import path from "path"; export const BS_EXECUTABLE = "Beat Saber.exe"; @@ -10,5 +11,8 @@ export const APP_NAME = "BSManager"; export const STEAMVR_APP_ID = "250820"; -export const IMAGE_CACHE_FOLDER = "imagescache"; -export const IMAGE_CACHE_PATH = path.join(app.getPath("userData"), IMAGE_CACHE_FOLDER); +export const CACHE_PATH = path.join(app.getPath("userData"), "CachedData"); + +export const IMAGE_CACHE_PATH = path.join(CACHE_PATH, "imagescache"); + +export const HTTP_STATUS_CODES = constants; diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts index 7fa474f7e..2ad48bb63 100644 --- a/src/main/helpers/fs.helpers.ts +++ b/src/main/helpers/fs.helpers.ts @@ -175,7 +175,7 @@ export async function dirSize(dirPath: string): Promise{ const realPath = await realpath(fullPath); const stat = await lstat(realPath); - if (stat.isDirectory()) { + if (stat.isDirectory()) { return dirSize(fullPath); } @@ -244,9 +244,10 @@ export function resolveGUIDPath(guidPath: string): string { return path.join(driveLetter, path.relative(guidVolume, guidPath)); } -export interface Progression { +export interface Progression { total: number; current: number; diff?: number; data?: T; + extra?: D; } diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index 24b4fef00..762ab6b72 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -1,4 +1,4 @@ -import { LocalMapsManagerService } from "../services/additional-content/local-maps-manager.service"; +import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service"; import { UtilsService } from "../services/utils.service"; import { BSVersion } from "shared/bs-version.interface"; import { IpcRequest } from "shared/models/ipc"; diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 6a0b5f61d..c95fdaa7c 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -5,36 +5,41 @@ import { of, throwError } from "rxjs"; const ipc = IpcService.getInstance(); ipc.on("one-click-install-playlist", (req, reply) => { - const mapsManager = LocalPlaylistsManagerService.getInstance(); - reply(mapsManager.oneClickInstallPlaylist(req.args)); + const playlists = LocalPlaylistsManagerService.getInstance(); + reply(playlists.oneClickInstallPlaylist(req.args)); }); ipc.on("register-playlists-deep-link", (_, reply) => { - const maps = LocalPlaylistsManagerService.getInstance(); + const playlists = LocalPlaylistsManagerService.getInstance(); try { - reply(of(maps.enableDeepLinks())); + reply(of(playlists.enableDeepLinks())); } catch (e) { reply(throwError(() => e)); } }); ipc.on("unregister-playlists-deep-link", (_, reply) => { - const maps = LocalPlaylistsManagerService.getInstance(); + const playlists = LocalPlaylistsManagerService.getInstance(); try { - reply(of(maps.disableDeepLinks())); + reply(of(playlists.disableDeepLinks())); } catch (e) { reply(throwError(() => e)); } }); ipc.on("is-playlists-deep-links-enabled", (_, reply) => { - const maps = LocalPlaylistsManagerService.getInstance(); + const playlists = LocalPlaylistsManagerService.getInstance(); try { - reply(of(maps.isDeepLinksEnabled())); + reply(of(playlists.isDeepLinksEnabled())); } catch (e) { reply(throwError(() => e)); } }); + +ipc.on("get-version-playlists", (req, reply) => { + const playlists = LocalPlaylistsManagerService.getInstance(); + reply(playlists.getVersionPlaylists(req.args)); +}); diff --git a/src/main/ipcs/bs-version-ipcs.ts b/src/main/ipcs/bs-version-ipcs.ts index 0c3170965..309450d45 100644 --- a/src/main/ipcs/bs-version-ipcs.ts +++ b/src/main/ipcs/bs-version-ipcs.ts @@ -10,7 +10,7 @@ import { from } from "rxjs"; import path from "path"; import { pathExist } from "../helpers/fs.helpers"; import { FolderLinkerService, LinkOptions } from "../services/folder-linker.service"; -import { LocalMapsManagerService } from "../services/additional-content/local-maps-manager.service"; +import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service"; import { readJSON, writeJSON } from "fs-extra"; import log from "electron-log"; import { VersionLinkerAction } from "renderer/services/version-folder-linker.service"; diff --git a/src/main/main.ts b/src/main/main.ts index c3d69279c..5ab5cfe26 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13,7 +13,7 @@ import "./ipcs"; import { WindowManagerService } from "./services/window-manager.service"; import { DeepLinkService } from "./services/deep-link.service"; import { AppWindow } from "shared/models/window-manager/app-window.model"; -import { LocalMapsManagerService } from "./services/additional-content/local-maps-manager.service"; +import { LocalMapsManagerService } from "./services/additional-content/maps/local-maps-manager.service"; import { LocalPlaylistsManagerService } from "./services/additional-content/local-playlists-manager.service"; import { LocalModelsManagerService } from "./services/additional-content/local-models-manager.service"; import { APP_NAME } from "./constants"; @@ -22,6 +22,7 @@ import { IpcRequest } from "shared/models/ipc"; import { LivShortcut } from "./services/liv/liv-shortcut.service"; import { SteamLauncherService } from "./services/bs-launcher/steam-launcher.service"; import { FileAssociationService } from "./services/file-association.service"; +import { SongDetailsCacheService } from "./services/additional-content/maps/song-details-cache.service"; const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true"; @@ -68,6 +69,7 @@ const initServicesMustBeInitialized = () => { LocalModelsManagerService.getInstance(); LivShortcut.getInstance(); BSLauncherService.getInstance(); + SongDetailsCacheService.getInstance(); } const findDeepLinkInArgs = (args: string[]): string => { @@ -102,6 +104,8 @@ if (!gotTheLock) { app.whenReady().then(() => { + // C:\\Users\\Mathieu\\Desktop\\BSManager\\BSInstances\\My Version\\UserData\\SongDetailsCache.proto + app.setAppUserModelId(APP_NAME); initServicesMustBeInitialized(); diff --git a/src/main/models/json-cache.class.ts b/src/main/models/json-cache.class.ts new file mode 100644 index 000000000..f3a76a9fe --- /dev/null +++ b/src/main/models/json-cache.class.ts @@ -0,0 +1,77 @@ +import { writeFileSync } from "fs-extra"; +import { tryit } from "shared/helpers/error.helpers"; +import log from "electron-log"; +import { Subject, debounceTime } from "rxjs"; + +export class JsonCache { + + private cache: Record = {}; + private readonly setEvent: Subject = new Subject(); + + public constructor( + private readonly jsonPath: string, + private readonly options: JsonCacheOptions = { autoSave: true, saveDebounce: 1000} + ){ + this.load(); + + if(this.options.autoSave){ + this.setEvent = new Subject(); + this.setEvent.pipe(debounceTime(this.options.saveDebounce ?? 1000)).subscribe(() => this.save()); + } + } + + private load(): void { + try { + this.cache = require(this.jsonPath); + } catch (error) { + log.warn("Failed to load cache or file cache not exist yet", this.jsonPath, error); + } finally { + this.cache ??= {}; + } + } + + public save(): void { + const res = tryit(() => writeFileSync(this.jsonPath, JSON.stringify(this.cache), { flush: true })); + if(res.error){ + log.error("Failed to save cache", this.jsonPath, res.error); + } + } + + public get(key: string): T { + return this.cache[key]; + } + + public set(key: string, value: T): void { + this.cache[key] = value; + this.setEvent?.next(); + } + + public delete(key: string): void { + delete this.cache[key]; + this.setEvent?.next(); + } + + public clear(): void { + this.cache = {}; + this.setEvent?.next(); + } + +} + +export type JsonCacheOptions = { + /** + * Load the cache immediately after creating the instance + * @default true + */ + loadImmediately?: boolean; + /** + * Auto save the cache after a change + * @default true + */ + autoSave?: boolean; + /** + * Time in ms to wait before saving the cache after a change + * @default 1000 + */ + saveDebounce?: number; +}; diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index a22d8e221..f69320751 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -4,13 +4,13 @@ import { BSVersion } from "shared/bs-version.interface"; import { BSLocalVersionService } from "../bs-local-version.service"; import { DeepLinkService } from "../deep-link.service"; import { RequestService } from "../request.service"; -import { LocalMapsManagerService } from "./local-maps-manager.service"; +import { LocalMapsManagerService } from "./maps/local-maps-manager.service"; import log from "electron-log"; import { WindowManagerService } from "../window-manager.service"; import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; -import { copy, copyFile, pathExists, realpath } from "fs-extra"; +import { copy, copyFile, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; @@ -100,6 +100,37 @@ export class LocalPlaylistsManagerService { this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`); } + private getReadBPListOfFolder(folerPath: string): Observable> { + return new Observable>(obs => { + + const progress: Progression = { current: 0, total: 0, data: [] }; + + (async () => { + if(!pathExistsSync(folerPath)) { + throw new Error(`Playlists folder not found ${folerPath}`); + } + + const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist"); + progress.total = playlists.length; + + for (const playlist of playlists) { + const playlistPath = path.join(folerPath, playlist); + const playlistContent = await this.readPlaylistFile(playlistPath); + progress.data.push(playlistContent); + progress.current += 1; + obs.next(progress); + } + })().catch(err => obs.error(err)).finally(() => obs.complete()); + }); + } + + public getVersionPlaylists(version: BSVersion): Observable> { + return new Observable>(obs => { + this.getPlaylistsFolder(version) + .then(folder => this.getReadBPListOfFolder(folder).subscribe(obs)) + }); + } + public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable> { return new Observable>(obs => { diff --git a/src/main/services/additional-content/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts similarity index 82% rename from src/main/services/additional-content/local-maps-manager.service.ts rename to src/main/services/additional-content/maps/local-maps-manager.service.ts index b30349aaa..d3172b6e4 100644 --- a/src/main/services/additional-content/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -2,26 +2,29 @@ import path from "path"; import { BSVersion } from "shared/bs-version.interface"; import { BsvMapDetail, RawMapInfoData } from "shared/models/maps"; import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface"; -import { BSLocalVersionService } from "../bs-local-version.service"; -import { InstallationLocationService } from "../installation-location.service"; -import { UtilsService } from "../utils.service"; +import { BSLocalVersionService } from "../../bs-local-version.service"; +import { InstallationLocationService } from "../../installation-location.service"; +import { UtilsService } from "../../utils.service"; import crypto from "crypto"; import { lstatSync } from "fs"; import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra"; import StreamZip from "node-stream-zip"; -import { RequestService } from "../request.service"; +import { RequestService } from "../../request.service"; import sanitize from "sanitize-filename"; -import { DeepLinkService } from "../deep-link.service"; +import { DeepLinkService } from "../../deep-link.service"; import log from "electron-log"; -import { WindowManagerService } from "../window-manager.service"; +import { WindowManagerService } from "../../window-manager.service"; import { Observable, lastValueFrom, of } from "rxjs"; -import { Archive } from "../../models/archive.class"; -import { deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../helpers/fs.helpers"; +import { Archive } from "../../../models/archive.class"; +import { deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers"; import { readFile } from "fs/promises"; -import { FolderLinkerService } from "../folder-linker.service"; -import { allSettled } from "../../../shared/helpers/promise.helpers"; -import { splitIntoChunk } from "../../../shared/helpers/array.helpers"; -import { IpcService } from "../ipc.service"; +import { FolderLinkerService } from "../../folder-linker.service"; +import { allSettled } from "../../../../shared/helpers/promise.helpers"; +import { splitIntoChunk } from "../../../../shared/helpers/array.helpers"; +import { IpcService } from "../../ipc.service"; +import { SongDetailsCacheService } from "./song-details-cache.service"; +import { sToMs } from "shared/helpers/time.helpers"; +import { SongCacheService } from "./song-cache.service"; export class LocalMapsManagerService { private static instance: LocalMapsManagerService; @@ -49,7 +52,9 @@ export class LocalMapsManagerService { private readonly deepLink: DeepLinkService; private readonly windows: WindowManagerService; private readonly ipc: IpcService; - private readonly linker = FolderLinkerService.getInstance(); + private readonly linker: FolderLinkerService; + private readonly songDetailsCache: SongDetailsCacheService; + private readonly songCache: SongCacheService; private constructor() { this.localVersion = BSLocalVersionService.getInstance(); @@ -60,6 +65,8 @@ export class LocalMapsManagerService { this.windows = WindowManagerService.getInstance(); this.linker = FolderLinkerService.getInstance(); this.ipc = IpcService.getInstance(); + this.songDetailsCache = SongDetailsCacheService.getInstance(); + this.songCache = SongCacheService.getInstance(); this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => { log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link); @@ -87,7 +94,7 @@ export class LocalMapsManagerService { const mapRawInfo: RawMapInfoData = JSON.parse(rawInfoString); const shasum = crypto.createHash("sha1"); shasum.update(rawInfoString); - + const hashFile = (filePath: string): Promise => { return new Promise((resolve, reject) => { const stream = createReadStream(filePath); @@ -103,11 +110,24 @@ export class LocalMapsManagerService { await hashFile(diffFilePath); } } - + return shasum.digest("hex"); } private async loadMapInfoFromPath(mapPath: string): Promise { + + const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => { + const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href; + const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href; + return { rawInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) } as BsmLocalMap; + }; + + const cachedInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath)); + + if (cachedInfos) { + return getUrlsAndReturn(cachedInfos.rawInfo, cachedInfos.hash, mapPath); + } + const files = await getFilesInFolder(mapPath); const infoFile = files.find(file => path.basename(file).toLowerCase() === "info.dat"); @@ -116,14 +136,10 @@ export class LocalMapsManagerService { } const rawInfoString = await readFile(infoFile, { encoding: "utf-8" }); - const rawInfo: RawMapInfoData = JSON.parse(rawInfoString); - const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href; - const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href; - const hash = await this.computeMapHash(mapPath, rawInfoString); - return { rawInfo, coverUrl, songUrl, hash, path: mapPath }; + return getUrlsAndReturn(rawInfo, hash, mapPath); } private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> { @@ -132,6 +148,7 @@ export class LocalMapsManagerService { await ensureFolderExist(this.utils.getTempPath()); const dest = path.join(tempPath, fileName); + const zipPath = (await lastValueFrom(this.reqService.downloadFile(zipUrl, dest))).data; const zip = new StreamZip.async({ file: zipPath }); @@ -140,14 +157,10 @@ export class LocalMapsManagerService { private openOneClickDownloadMapWindow(mapId: string, isHash = false): void { this.windows.openWindow("oneclick-download-map.html").then(window => { - this.ipc.once("one-click-map-info", async (_, reply) => { reply(of({ id: mapId, isHash })); }, window.webContents.ipc); - }); - - } public getMaps(version?: BSVersion): Observable { @@ -159,6 +172,8 @@ export class LocalMapsManagerService { return new Observable(observer => { (async () => { + await this.songDetailsCache.waitLoaded(sToMs(30)); + const levelsFolder = await this.getMapsFolderPath(version); if(!(await pathExist(levelsFolder))) { @@ -180,6 +195,8 @@ export class LocalMapsManagerService { return null; } + this.songCache.setMapInfoFromDirname(path.basename(mapPath), { rawInfo: mapInfo.rawInfo, hash: mapInfo.hash }); + progression.loaded++; observer.next(progression); return mapInfo; @@ -235,6 +252,7 @@ export class LocalMapsManagerService { continue; } await deleteFolder(folder); + this.songCache.deleteMapInfoFromDirname(path.basename(folder)); progress.deleted++; observer.next(progress); } @@ -248,7 +266,7 @@ export class LocalMapsManagerService { public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise { if (!map.versions.at(0).hash) { - throw "Cannot download map, no hash found"; + throw new Error("Cannot download map, no hash found"); } const zipUrl = map.versions.at(0).downloadURL; @@ -261,7 +279,7 @@ export class LocalMapsManagerService { if(!exists){ return null; } return this.loadMapInfoFromPath(mapPath); }).catch(() => null); - + if(map.versions.every(version => version.hash === installedMap?.hash)) { return installedMap; } @@ -269,7 +287,7 @@ export class LocalMapsManagerService { const { zip, zipPath } = await this.downloadMapZip(zipUrl); if (!zip) { - throw `Cannot download ${zipUrl}`; + throw new Error(`Cannot download ${zipUrl}`); } await ensureFolderExist(mapPath); @@ -279,7 +297,7 @@ export class LocalMapsManagerService { await unlink(zipPath); const localMap = await this.loadMapInfoFromPath(mapPath); - localMap.bsaverInfo = map; + localMap.songDetails = this.songDetailsCache.getSongDetails(localMap.hash); return localMap; } diff --git a/src/main/services/additional-content/maps/song-cache.service.ts b/src/main/services/additional-content/maps/song-cache.service.ts new file mode 100644 index 000000000..486193514 --- /dev/null +++ b/src/main/services/additional-content/maps/song-cache.service.ts @@ -0,0 +1,42 @@ +import { CACHE_PATH } from "main/constants"; +import { JsonCache } from "main/models/json-cache.class"; +import path from "path"; +import { RawMapInfoData } from "shared/models/maps"; + +export class SongCacheService { + + private static instance: SongCacheService; + + public static getInstance(): SongCacheService { + if (!SongCacheService.instance) { + SongCacheService.instance = new SongCacheService(); + } + return SongCacheService.instance; + } + + private readonly RAW_INFOS_CACHE_PATH = path.join(CACHE_PATH, "song-raw-info-cache.json"); + + private readonly rawInfosCache: JsonCache; + + private constructor(){ + this.rawInfosCache = new JsonCache(this.RAW_INFOS_CACHE_PATH); + } + + public getMapInfoFromDirname(dirname: string): CachedRawInfoWithHash { + return this.rawInfosCache.get(dirname); + } + + public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void { + this.rawInfosCache.set(dirname, info); + } + + public deleteMapInfoFromDirname(dirname: string): void { + this.rawInfosCache.delete(dirname); + } + +} + +export type CachedRawInfoWithHash = { + hash: string; + rawInfo: RawMapInfoData; +}; diff --git a/src/main/services/additional-content/maps/song-details-cache.service.ts b/src/main/services/additional-content/maps/song-details-cache.service.ts new file mode 100644 index 000000000..ceed522ee --- /dev/null +++ b/src/main/services/additional-content/maps/song-details-cache.service.ts @@ -0,0 +1,167 @@ +import path from "path"; +import { ensureDirSync, existsSync, readFile, writeFile } from "fs-extra"; +import { BehaviorSubject, Observable, catchError, filter, lastValueFrom, of, take, timeout } from "rxjs"; +import { ConfigurationService } from "../../configuration.service"; +import { RequestService } from "../../request.service"; +import { tryit } from "shared/helpers/error.helpers"; +import { CACHE_PATH, HTTP_STATUS_CODES } from "main/constants"; +import log from "electron-log"; +import protobuf from "protobufjs"; +import { UtilsService } from "../../utils.service"; +import { SongDetails, SongDetailsCache } from "shared/models/maps/song-details-cache.model"; +import { inflate } from "pako"; + +export class SongDetailsCacheService { + + private static instance: SongDetailsCacheService; + + public static getInstance(): SongDetailsCacheService { + if (!SongDetailsCacheService.instance) { + SongDetailsCacheService.instance = new SongDetailsCacheService(); + } + return SongDetailsCacheService.instance; + } + + private readonly dataSource = [ + "https://raw.githubusercontent.com/Zagrios/beat-saber-scraped-maps/master/song_details_cache_v1.gz", + "https://cdn.jsdelivr.net/gh/Zagrios/beat-saber-scraped-maps@master/song_details_cache_v1.gz", + ] + + private readonly PROTO_CACHE_PATH = path.join(CACHE_PATH, "song-details-cache"); + private readonly etagKey = "song-details-cache-etag"; + + private readonly config: ConfigurationService; + private readonly request: RequestService; + private readonly utils: UtilsService; + + private songDetailsCache: Record = {}; + private readonly _loaded$ = new BehaviorSubject(null); + + private constructor(){ + this.config = ConfigurationService.getInstance(); + this.request = RequestService.getInstance(); + this.utils = UtilsService.getInstance(); + this.loadCache() + } + + private async loadCache(): Promise { + const protoCacheExists = existsSync(this.PROTO_CACHE_PATH); + const etag = protoCacheExists ? this.config.get(this.etagKey) : null; + + await this.downloadCacheFile(etag).then(etag => { + this.config.set(this.etagKey, etag); + }).catch(err => { + log.error("Unable to download cache file", err); + }); + + this.readProtoMessageCacheFile(this.PROTO_CACHE_PATH).then(cache => { + this.songDetailsCache = cache; + log.info("SongDetailsCache loaded"); + }).catch(err => { + log.error("Failed to read cache file", this.PROTO_CACHE_PATH, err); + }).finally(() => { + this._loaded$.next(true); + }) + } + + private async readProtoMessageCacheFile(filePath: string): Promise> { + const protobufRoot = await protobuf.load(this.getProtoShemaPath()); + const cacheMessage = protobufRoot.lookupType("SongDetailsCache"); + + const buffer = await readFile(filePath); + + const messageBuffer = cacheMessage.decode(buffer); + const messageObj = cacheMessage.toObject(messageBuffer) as SongDetailsCache; + + const res: Record = {}; + + for(const song of messageObj.songs){ + res[song.hash.toLocaleLowerCase()] = song; + } + + return res; + } + + /** + * Download the GZipped Proto file and write it to the cache destination + * @param etag + * @returns {string} new etag or the same if the file is the same + */ + private async downloadCacheFile(etag?: string): Promise { + const { buffer, etag: newEtag } = await this.downloadGZCacheFile(etag); + + if(!buffer) { return etag; } + + ensureDirSync(path.dirname(this.PROTO_CACHE_PATH)); + + await writeFile(this.PROTO_CACHE_PATH, inflate(buffer), { encoding: "binary" }); + + return newEtag; + } + + /** + * Download the GZipped Proto file from the sources + * @returns {Promise<{ buffer: Buffer, etag: string }>} {\ + * buffer: GZipper Buffer, will be empty if etag is the same\ + * etag: ETag of the file, should be never empty\ + * } + */ + private async downloadGZCacheFile(etag?: string): Promise<{ buffer: Buffer, etag: string }> { + + let lastError: Error; + + for(const sourceUrl of this.dataSource){ + + const { result, error } = await tryit(() => { + return lastValueFrom(this.request.downloadBuffer(sourceUrl, { + headers: etag ? { "If-None-Match": etag } : {}, + decompress: false + })).then(res => ({ buffer: res.data, request: res.extra})); + }); + + + if(error) { + lastError = error; + continue; + } + + log.info("Downloaded SongDetailCache file from source:", sourceUrl, "ETAG:", result.request.headers.etag, result.request.statusCode); + + return { + buffer: result.request.statusCode === HTTP_STATUS_CODES.HTTP_STATUS_NOT_MODIFIED ? null : result.buffer, + etag: result.request.headers.etag + } + } + + log.error("Failed to download SongDetailCache file", etag, lastError); + throw lastError; + } + + private getProtoShemaPath(): string { + return this.utils.getAssetsPath(path.join("protos", "song_details_cache_v1.proto")) + } + + public get loaded$(): Observable { + return this._loaded$.pipe(filter(val => typeof val === "boolean")); + } + + /** + * Promise that resolves when the cache is loaded (loaded does not mean cache contains data, just the all load process is done) + * @param timeoutMs in milliseconds + * @throws {TimeoutError} if the cache is not ready after the provided timeout + */ + public waitLoaded(timeoutMs: number): Promise { + + const obs = this.loaded$.pipe(take(1)); + + return lastValueFrom(obs.pipe(timeout(timeoutMs), catchError((err => { + log.error("Wait loaded SongDetailsCache timed out", err); + return of(false); + })))); + } + + public getSongDetails(hash: string): any { + return this.songDetailsCache[hash.toLocaleLowerCase()]; + } + +} diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index eb33c388a..34543bc45 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -92,7 +92,7 @@ export class BSLocalVersionService { } public async getVersionOfBSFolder( - bsPath: string, + bsPath: string, options?: { steam?: boolean; oculus?: boolean; @@ -126,7 +126,7 @@ export class BSLocalVersionService { // Will be removed in future version. It just to prepare future features if(!metadata?.id){ - metadata = await this.initVersionMetadata(folderVersion, metadata ?? { store: BsStore.STEAM }); + metadata = await this.initVersionMetadata(folderVersion, metadata ?? { store: BsStore.STEAM }); } folderVersion.metadata = metadata; @@ -182,8 +182,8 @@ export class BSLocalVersionService { /** - * Return path of a version even if it's not installed. - * @param {BSVersion} version + * Return path of a version even if it's not installed. + * @param {BSVersion} version * @returns {Promise} */ public async getVersionPath(version: BSVersion): Promise{ @@ -191,25 +191,25 @@ export class BSLocalVersionService { if(version.oculus){ return this.oculusService.tryGetGameFolder([OCULUS_BS_DIR, OCULUS_BS_BACKUP_DIR]); } return path.join( - await this.installLocationService.versionsDirectory(), + this.installLocationService.versionsDirectory(), this.getVersionFolder(version) ); } /** * Return path of an installed version. Returns null if not found. - * @param {BSVersion} version + * @param {BSVersion} version * @returns {Promise} */ public async getInstalledVersionPath(version: BSVersion): Promise{ const versionPath = await this.getVersionPath(version); if(await pathExists(versionPath)){ return versionPath; } - const versionFolders = await getFoldersInFolder(await this.installLocationService.versionsDirectory()); + const versionFolders = await getFoldersInFolder(this.installLocationService.versionsDirectory()); for(const folder of versionFolders){ const stats = await lstat(folder); - if(stats.ino === version.ino){ + if(stats.ino === version.ino){ return folder; } } @@ -270,11 +270,11 @@ export class BSLocalVersionService { versions.push(oculusVersion); } - if (!(await pathExists(await this.installLocationService.versionsDirectory()))) { + if (!(await pathExists(this.installLocationService.versionsDirectory()))) { return versions; } - const folderInInstallation = await getFoldersInFolder(await this.installLocationService.versionsDirectory()); + const folderInInstallation = await getFoldersInFolder(this.installLocationService.versionsDirectory()); log.info("Finded versions folders", folderInInstallation); diff --git a/src/main/services/bs-version-download/bs-steam-downloader.service.ts b/src/main/services/bs-version-download/bs-steam-downloader.service.ts index 2660b929a..ba7d0d546 100644 --- a/src/main/services/bs-version-download/bs-steam-downloader.service.ts +++ b/src/main/services/bs-version-download/bs-steam-downloader.service.ts @@ -82,7 +82,7 @@ export class BsSteamDownloaderService { qr } - await ensureDir(await this.installLocationService.versionsDirectory()); + await ensureDir(this.installLocationService.versionsDirectory()); const isLinux = process.platform === 'linux'; const exePath = this.getDepotDownloaderExePath(); @@ -91,7 +91,7 @@ export class BsSteamDownloaderService { const depotDownloader = new DepotDownloader({ command: isLinux ? 'dotnet' : exePath, args: isLinux ? [exePath, ...args] : args, - options: { cwd: await this.installLocationService.versionsDirectory() }, + options: { cwd: this.installLocationService.versionsDirectory() }, echoStartData: downloadVersion }, log); diff --git a/src/main/services/configuration.service.ts b/src/main/services/configuration.service.ts index bc9713b01..9049a0095 100644 --- a/src/main/services/configuration.service.ts +++ b/src/main/services/configuration.service.ts @@ -23,11 +23,12 @@ export class ConfigurationService { } private async initStore() { - const contentPath = await this.locations.installationDirectory(); + const contentPath = this.locations.installationDirectory(); this.store = new ElectronStore({ cwd: contentPath, name: "config", fileExtension: "cfg", + accessPropertiesByDotNotation: false, }); } diff --git a/src/main/services/installation-location.service.ts b/src/main/services/installation-location.service.ts index 7445ba2a3..26cad9833 100644 --- a/src/main/services/installation-location.service.ts +++ b/src/main/services/installation-location.service.ts @@ -1,7 +1,8 @@ import path from "path"; import { app } from "electron"; import ElectronStore from "electron-store"; -import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist, pathExist } from "../helpers/fs.helpers"; +import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist } from "../helpers/fs.helpers"; +import { pathExistsSync } from "fs-extra"; export class InstallationLocationService { private static instance: InstallationLocationService; @@ -17,6 +18,7 @@ export class InstallationLocationService { public readonly VERSIONS_FOLDER = "BSInstances"; private readonly SHARED_CONTENT_FOLDER = "SharedContent"; + private readonly CACHE_FOLDER = "cache"; private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder"; @@ -39,7 +41,7 @@ export class InstallationLocationService { public async setInstallationDirectory(newDir: string): Promise { newDir = path.basename(newDir) === this.INSTALLATION_FOLDER ? path.join(newDir, "..") : newDir; - const oldDir = await this.installationDirectory(); + const oldDir = this.installationDirectory(); await ensureFolderExist(oldDir); await copyDirectoryWithJunctions(oldDir, path.join(newDir, this.INSTALLATION_FOLDER), { overwrite: true }); @@ -56,9 +58,9 @@ export class InstallationLocationService { this.updateListeners.add(fn); } - public async installationDirectory(): Promise { + public installationDirectory(): string { - const installParentPath = async () => { + const installParentPath = () => { if(this._installationDirectory) { return this._installationDirectory; } @@ -68,24 +70,28 @@ export class InstallationLocationService { } const oldPath = path.join(app.getPath("documents"), this.INSTALLATION_FOLDER); - if(await pathExist(oldPath)){ + if(pathExistsSync(oldPath)){ return app.getPath("documents"); } return app.getPath("home"); }; - this._installationDirectory = await installParentPath(); + this._installationDirectory = installParentPath(); return path.join(this._installationDirectory, this.INSTALLATION_FOLDER); } - public async versionsDirectory(): Promise { - return path.join(await this.installationDirectory(), this.VERSIONS_FOLDER); + public versionsDirectory(): string { + return path.join(this.installationDirectory(), this.VERSIONS_FOLDER); } - public async sharedContentPath(): Promise { - return path.join(await this.installationDirectory(), this.SHARED_CONTENT_FOLDER); + public sharedContentPath(): string { + return path.join(this.installationDirectory(), this.SHARED_CONTENT_FOLDER); + } + + public cachePath(): string { + return path.join(this.installationDirectory(), this.CACHE_FOLDER); } } diff --git a/src/main/services/ipc.service.ts b/src/main/services/ipc.service.ts index cd1f8f50b..926048836 100644 --- a/src/main/services/ipc.service.ts +++ b/src/main/services/ipc.service.ts @@ -1,5 +1,5 @@ import { IpcRequest } from "shared/models/ipc"; -import { BrowserWindow, ipcMain } from "electron"; +import { BrowserWindow, IpcMainEvent, ipcMain } from "electron"; import { Observable } from "rxjs"; import { IpcCompleteChannel, IpcErrorChannel, IpcTearDownChannel } from "shared/models/ipc/ipc-response.interface"; import { IpcReplier } from "shared/models/ipc/ipc-request.interface"; @@ -31,7 +31,7 @@ export class IpcService { } private buildProxyListener(listener: IpcListener) { - return (event: Electron.IpcMainEvent, req: IpcRequest) => { + return (event: IpcMainEvent, req: IpcRequest) => { const window = BrowserWindow.fromWebContents(event.sender); const replier = (data: Observable) => this.connectStream(req.responceChannel, window, data); listener(req, replier); diff --git a/src/main/services/request.service.ts b/src/main/services/request.service.ts index dbd2fcf9c..ca8007301 100644 --- a/src/main/services/request.service.ts +++ b/src/main/services/request.service.ts @@ -4,6 +4,8 @@ import { Progression } from "main/helpers/fs.helpers"; import { Observable, shareReplay, tap } from "rxjs"; import log from "electron-log"; import fetch, { RequestInfo, RequestInit } from "node-fetch"; +import got, { GotOptions } from "got"; +import { IncomingMessage } from "http"; export class RequestService { private static instance: RequestService; @@ -30,7 +32,7 @@ export class RequestService { throw new Error(`HTTP error! status: ${response.status} ${url}`); } - return await response.json(); + return await response.json() as T; } catch (err) { log.error(err); throw err; @@ -67,7 +69,7 @@ export class RequestService { }).pipe(tap({ error: e => log.error(e, url, dest) }), shareReplay(1)); } - public downloadBuffer(url: string): Observable> { + public downloadBuffer(url: string, options?: GotOptions): Observable> { return new Observable>(subscriber => { const progress: Progression = { current: 0, @@ -75,27 +77,42 @@ export class RequestService { data: null, }; - const allChunks: Buffer[] = []; + const req = got.stream(url, options); - const req = get(url, { agent: this.ipv4Agent }, res => { - progress.total = parseInt(res.headers?.["content-length"] || "0", 10); + let data = Buffer.alloc(0); + let response: IncomingMessage; - res.on("data", chunk => { - allChunks.push(chunk); - progress.current += chunk.length; - subscriber.next(progress); - }); - res.on("end", () => { - progress.data = Buffer.concat(allChunks); - subscriber.next(progress); - subscriber.complete(); - }); - res.on("error", err => subscriber.error(err)); + req.once("response", res => { + response = res; }); - req.on("error", err => { + req.on("data", (chunk: Buffer) => { + data = Buffer.concat([data, chunk]); + }) + + req.on("downloadProgress", ({ transferred, total }) => { + progress.current = transferred; + progress.total = total; + subscriber.next(progress); + }); + + req.once("error", err => { subscriber.error(err); }); + + req.once("end", () => { + progress.data = data; + progress.extra = response; + subscriber.next(progress); + subscriber.complete(); + }); + + req.resume(); + + return () => { + req.destroy(); + } + }).pipe(tap({ error: e => log.error(e) }), shareReplay(1)); } } diff --git a/src/main/services/version-folder-linker.service.ts b/src/main/services/version-folder-linker.service.ts index 1a8635b02..3cb3d76ec 100644 --- a/src/main/services/version-folder-linker.service.ts +++ b/src/main/services/version-folder-linker.service.ts @@ -2,7 +2,7 @@ import { getFoldersInFolder } from "../helpers/fs.helpers"; import path from "path"; import { VersionLinkerAction, VersionLinkFolderAction, VersionUnlinkFolderAction } from "renderer/services/version-folder-linker.service"; import { BSVersion } from "shared/bs-version.interface"; -import { LocalMapsManagerService } from "./additional-content/local-maps-manager.service"; +import { LocalMapsManagerService } from "./additional-content/maps/local-maps-manager.service"; import { BSLocalVersionService } from "./bs-local-version.service"; import { FolderLinkerService, LinkOptions } from "./folder-linker.service"; import { allSettled } from "../../shared/helpers/promise.helpers"; diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 24243e05b..0b9d12a27 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -3,13 +3,11 @@ import { BSVersion } from "shared/bs-version.interface"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { Subscription, BehaviorSubject } from "rxjs"; -import { MapFilter } from "shared/models/maps/beat-saver.model"; +import { MapFilter, MapTag } from "shared/models/maps/beat-saver.model"; import { MapsDownloaderService } from "renderer/services/maps-downloader.service"; import { VariableSizeList } from "react-window"; import { MapsRow } from "./maps-row.component"; -import { debounceTime, last, mergeMap, tap } from "rxjs/operators"; -import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service"; -import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; +import { debounceTime, last, tap } from "rxjs/operators"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import BeatWaitingImg from "../../../../../assets/images/apngs/beat-waiting.png"; import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; @@ -33,8 +31,6 @@ type Props = { export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }: Props, forwardRef) => { const mapsManager = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); - const bsaver = useService(BeatSaverService); - const os = useService(OsDiagnosticService); const t = useTranslation(); const ref = useRef(null); @@ -43,7 +39,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear const [selectedMaps$] = useState(new BehaviorSubject([])); const [itemPerRow, setItemPerRow] = useState(2); const [listHeight, setListHeight] = useState(0); - const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true, emitOnEqual: true }); + const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); const [linked, setLinked] = useState(false); const [loadPercent$] = useState(new BehaviorSubject(0)); @@ -135,21 +131,10 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear loadMapsObs$ .pipe( tap(progress => loadPercent$.next(Math.floor((progress.loaded / progress.total) * 100))), - last(), - mergeMap(async progress => { - if (os.isOffline) { - return progress.maps; - } - const { maps } = progress; - const details = await bsaver.getMapDetailsFromHashs(maps.map(map => map.hash)); - return maps.map(map => { - map.bsaverInfo = details.find(d => d.versions.at(0).hash === map.hash); - return map; - }); - }) + last() ) .subscribe({ - next: maps => setMaps(() => maps), + next: progress => setMaps(() => progress.maps), complete: () => loadPercent$.next(0), }) ); @@ -194,13 +179,14 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear // Can be more clean and optimized i think const fitEnabledTags = (() => { + if (!filter?.enabledTags || filter.enabledTags.size === 0) { return true; } - if (!map?.bsaverInfo?.tags) { + if (!map?.songDetails?.tags) { return false; } - return Array.from(filter.enabledTags.values()).every(tag => map.bsaverInfo.tags.some(mapTag => mapTag === tag)); + return Array.from(filter.enabledTags.values()).every(tag => map.songDetails.tags.some(mapTag => mapTag === tag)); })(); if (!fitEnabledTags) { @@ -211,10 +197,10 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.excludedTags || filter.excludedTags.size === 0) { return true; } - if (!map?.bsaverInfo?.tags) { + if (!map?.songDetails?.tags) { return true; } - return !map.bsaverInfo.tags.some(tag => filter.excludedTags.has(tag)); + return !map.songDetails?.tags.some(tag => filter.excludedTags.has(tag as MapTag)); })(); if (!fitExcluedTags) { @@ -225,12 +211,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.minNps) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return !map.bsaverInfo.versions.some(version => { - return version.diffs.some(diff => diff.nps < filter.minNps); - }); + + return map.songDetails?.difficulties.some(diff => diff.nps > filter.minNps); })(); if (!fitMinNps) { @@ -241,12 +223,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.maxNps) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return !map.bsaverInfo.versions.some(version => { - return version.diffs.some(diff => diff.nps > filter.maxNps); - }); + + return map.songDetails?.difficulties.some(diff => diff.nps < filter.maxNps); })(); if (!fitMaxNps) { @@ -258,10 +236,10 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear return true; } - if (!map?.bsaverInfo?.metadata?.duration) { + if (!map?.songDetails?.metadata?.duration) { return false; } - return map.bsaverInfo.metadata.duration >= filter.minDuration; + return map.songDetails?.metadata.duration >= filter.minDuration; })(); if (!fitMinDuration) { @@ -272,10 +250,10 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.maxDuration) { return true; } - if (!map?.bsaverInfo?.metadata?.duration) { + if (!map?.songDetails?.metadata?.duration) { return false; } - return map.bsaverInfo.metadata.duration <= filter.maxDuration; + return map.songDetails?.metadata.duration <= filter.maxDuration; })(); if (!fitMaxDuration) { @@ -286,10 +264,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.noodle) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return map.bsaverInfo.versions.some(version => version.diffs.some(diff => !!diff.ne)); + return map.songDetails?.difficulties.some(diff => !!diff.ne); })(); if (!fitNoodle) { @@ -300,10 +275,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.me) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return map.bsaverInfo.versions.some(version => version.diffs.some(diff => !!diff.me)); + + return map.songDetails?.difficulties.some(diff => !!diff.me); })(); if (!fitMe) { @@ -314,10 +287,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.cinema) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return map.bsaverInfo.versions.some(version => version.diffs.some(diff => !!diff.cinema)); + + return map.songDetails?.difficulties.some(diff => !!diff.cinema); })(); if (!fitCinema) { @@ -328,10 +299,8 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.chroma) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return map.bsaverInfo.versions.some(version => version.diffs.some(diff => !!diff.chroma)); + + return map.songDetails?.difficulties.some(diff => !!diff.chroma); })(); if (!fitChroma) { @@ -342,31 +311,29 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!filter?.fullSpread) { return true; } - if (!map?.bsaverInfo?.versions?.at(0)) { - return false; - } - return map.bsaverInfo.versions.some(version => version?.diffs?.length >= 5); + + return map.songDetails?.difficulties.length >= 5; })(); if (!fitFullSpread) { return false; } - if (!(filter?.automapper ? map.bsaverInfo?.automapper === filter.automapper : true)) { + if (filter?.automapper && (map.songDetails && !map.songDetails?.automapper)) { return false; } - if (!(filter?.ranked ? map.bsaverInfo?.ranked === filter.ranked : true)) { + if (!(filter?.ranked ? map.songDetails?.ranked === filter.ranked : true)) { return false; } - if (!(filter?.curated ? !!map.bsaverInfo?.curatedAt : true)) { + if (!(filter?.curated ? !!map.songDetails?.curated === filter.curated : true)) { return false; } - if (!(filter?.verified ? !!map.bsaverInfo?.uploader?.verifiedMapper : true)) { + if (!(filter?.verified ? !!map.songDetails?.uploader?.verified : true)) { return false; } const searchCheck = (() => { - return ((map.rawInfo?._songName ?? map.bsaverInfo?.name) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._songAuthorName ?? map.bsaverInfo?.metadata?.songAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._levelAuthorName ?? map.bsaverInfo?.metadata?.levelAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()); + return ((map.rawInfo?._songName ?? map.songDetails?.name) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._songAuthorName ?? map.songDetails?.metadata?.songAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._levelAuthorName ?? map.songDetails?.metadata?.levelAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()); })(); if (!searchCheck) { diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index f2ef0b2c3..4afd25955 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -1,5 +1,5 @@ import { BsmImage } from "../../shared/bsm-image.component"; -import { BsvMapCharacteristic, BsvMapDifficultyType } from "shared/models/maps/beat-saver.model"; +import { BsvMapDifficultyType } from "shared/models/maps/beat-saver.model"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { BsmLink } from "../../shared/bsm-link.component"; import { BsmIcon } from "../../svgs/bsm-icon.component"; @@ -22,8 +22,9 @@ import { GlowEffect } from "../../shared/glow-effect.component"; import { useDelayedState } from "renderer/hooks/use-delayed-state.hook"; import { useService } from "renderer/hooks/use-service.hook"; import Tippy from "@tippyjs/react"; +import { SongDetailDiffCharactertistic, SongDiffName } from "shared/models/maps"; -export type ParsedMapDiff = { type: BsvMapDifficultyType; name: string; stars: number }; +export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number }; export type MapItemProps = { hash: string; @@ -34,12 +35,12 @@ export type MapItemProps = { songUrl: string; autorId: number; mapId: string; - diffs: Map; + diffs: Map; ranked: boolean; bpm: number; duration: number; likes: number; - createdAt: string; + createdAt: number; selected?: boolean; downloading?: boolean; showOwned?: boolean; @@ -165,7 +166,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
  • - {stars ? ★ {stars} : {parseDiffLabel(type)}} + {stars ? ★ {stars.toFixed(2)} : {parseDiffLabel(type)}} {parseDiffLabel(name)}
  • diff --git a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx index 0e99fd274..580ae2a35 100644 --- a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx @@ -2,10 +2,10 @@ import { CSSProperties, memo } from "react"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { distinctUntilChanged, map } from "rxjs/operators"; import { BehaviorSubject } from "rxjs"; -import { BsvMapCharacteristic } from "shared/models/maps/beat-saver.model"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { ParsedMapDiff, MapItem } from "./map-item.component"; import equal from "fast-deep-equal/es6"; +import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache.model"; type Props = { maps: BsmLocalMap[]; @@ -21,10 +21,10 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel distinctUntilChanged(equal), ), []); - const extractMapDiffs = (map: BsmLocalMap): Map => { - const res = new Map(); - if (map.bsaverInfo?.versions[0]?.diffs) { - map.bsaverInfo.versions[0].diffs.forEach(diff => { + const extractMapDiffs = (map: BsmLocalMap): Map => { + const res = new Map(); + if (map.songDetails?.difficulties) { + map.songDetails?.difficulties.forEach(diff => { const arr = res.get(diff.characteristic) || []; const diffName = map.rawInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty; arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars }); @@ -45,24 +45,26 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel }; const renderMapItem = (map: BsmLocalMap) => { - return selected.hash === map.hash)} - diffs={extractMapDiffs(map)} mapId={map.bsaverInfo?.id} - ranked={map.bsaverInfo?.ranked} - autorId={map.bsaverInfo?.uploader?.id} - likes={map.bsaverInfo?.stats?.upvotes} - createdAt={map.bsaverInfo?.createdAt} - onDelete={onMapDelete} - onSelected={onMapSelect} + + return selected.hash === map.hash)} + diffs={extractMapDiffs(map)} + mapId={map.songDetails?.id} + ranked={map.songDetails?.ranked} + autorId={map.songDetails?.uploader.id} + likes={map.songDetails?.upVotes} + createdAt={map.songDetails?.uploadedAt} + onDelete={onMapDelete} + onSelected={onMapSelect} callBackParam={map} />; }; diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index dce4c93d0..63dd7fcd0 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -1,5 +1,8 @@ +import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; import { BSVersion } from "shared/bs-version.interface"; +import { noop } from "shared/helpers/function.helpers"; type Props = { version: BSVersion; @@ -9,6 +12,17 @@ type Props = { }; export function LocalPlaylistsListPanel({ version, className, isActive, linkedState }: Props) { + + const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); + + useOnUpdate(() => { + + if(!isActiveOnce){ return noop(); } + + // load playlists + + }, [isActiveOnce]); + return (
    local-playlists-list-panel.component
    ) diff --git a/src/renderer/hooks/use-change-until-equal.hook.ts b/src/renderer/hooks/use-change-until-equal.hook.ts index 3d1b35df1..d20d64a8a 100644 --- a/src/renderer/hooks/use-change-until-equal.hook.ts +++ b/src/renderer/hooks/use-change-until-equal.hook.ts @@ -3,32 +3,31 @@ import { useEffect, useRef, useState } from "react"; type Options = { untilEqual: T; - emitOnEqual?: boolean; } -export function useChangeUntilEqual(variableValue: T, { untilEqual, emitOnEqual }: Options): T { +export function useChangeUntilEqual(variableValue: T, { untilEqual }: Options ): T { const [value, setValue] = useState(variableValue); const untilEqualRef = useRef(untilEqual); - const emitOnEqualRef = useRef(emitOnEqual); const hasBeenEqual = useRef(false); useEffect(() => { + if(hasBeenEqual.current) { return; } const isEqual = equal(variableValue, untilEqualRef.current); + console.log("isEqual", isEqual, variableValue, untilEqualRef.current); + if(!isEqual){ + console.log("setValue", variableValue); return setValue(variableValue); } hasBeenEqual.current = true; - - if(emitOnEqualRef.current) { - setValue(variableValue); - } + setValue(variableValue); }, [variableValue]); return value; diff --git a/src/renderer/partials/maps/map-tags/map-styles.ts b/src/renderer/partials/maps/map-tags/map-styles.ts index a66aef1dd..22f7ed307 100644 --- a/src/renderer/partials/maps/map-tags/map-styles.ts +++ b/src/renderer/partials/maps/map-tags/map-styles.ts @@ -1,3 +1,3 @@ import { MapStyle } from "shared/models/maps/beat-saver.model"; -export const MAP_STYLES: MapStyle[] = ["dance", "swing", "nightcore", "folk", "family", "ambient", "funk", "jazz", "soul", "speedcore", "punk", "rb", "holiday", "vocaloid", "jrock", "trance", "drumbass", "comedy", "instrumental", "hardcore", "kpop", "indie", "techno", "house", "game", "film", "alt", "dubstep", "metal", "anime", "hiphop", "jpop", "rock", "pop", "electronic", "classical-orchestral"]; +export const MAP_STYLES: MapStyle[] = ["dance", "swing", "nightcore", "folk", "family", "ambient", "funk", "jazz", "soul", "speedcore", "punk", "rb", "holiday", "vocaloid", "j-rock", "trance", "drumbass", "comedy", "instrumental", "hardcore", "k-pop", "indie", "techno", "house", "game", "film", "alt", "dubstep", "metal", "anime", "hiphop", "j-pop", "rock", "pop", "electronic", "classical-orchestral"]; diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index 707f49d0a..c974cffea 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -23,6 +23,10 @@ export class PlaylistsManagerService { this.linker = VersionFolderLinkerService.getInstance(); } + public getVersionPlaylists(version: BSVersion): Promise { + return this.ipc.sendV2("get-version-playlists", version); + } + public isDeepLinksEnabled(): Promise { return lastValueFrom(this.ipc.sendV2("is-playlists-deep-links-enabled")); } diff --git a/src/shared/helpers/function.helpers.ts b/src/shared/helpers/function.helpers.ts index 6f17657d3..62097d50c 100644 --- a/src/shared/helpers/function.helpers.ts +++ b/src/shared/helpers/function.helpers.ts @@ -1,3 +1,5 @@ export function isFunction(value: any): value is Function { return !!(value && value.constructor && value.call && value.apply) } + +export function noop() {} diff --git a/src/shared/models/maps/beat-saver.model.ts b/src/shared/models/maps/beat-saver.model.ts index aec24b934..0cb2968b1 100644 --- a/src/shared/models/maps/beat-saver.model.ts +++ b/src/shared/models/maps/beat-saver.model.ts @@ -143,7 +143,7 @@ export interface BsvMapParitySummary { export type BsvMapCharacteristic = "Standard" | "OneSaber" | "NoArrows" | "90Degree" | "360Degree" | "Lightshow" | "Lawless"; export type BsvMapDifficultyType = "Easy" | "Normal" | "Hard" | "Expert" | "ExpertPlus"; -export type MapStyle = "dance" | "swing" | "nightcore" | "folk" | "family" | "ambient" | "funk" | "jazz" | "soul" | "speedcore" | "punk" | "rb" | "holiday" | "vocaloid" | "jrock" | "trance" | "drumbass" | "comedy" | "instrumental" | "hardcore" | "kpop" | "indie" | "techno" | "house" | "game" | "film" | "alt" | "dubstep" | "metal" | "anime" | "hiphop" | "jpop" | "rock" | "pop" | "electronic" | "classical-orchestral"; +export type MapStyle = "dance" | "swing" | "nightcore" | "folk" | "family" | "ambient" | "funk" | "jazz" | "soul" | "speedcore" | "punk" | "rb" | "holiday" | "vocaloid" | "j-rock" | "trance" | "drumbass" | "comedy" | "instrumental" | "hardcore" | "k-pop" | "indie" | "techno" | "house" | "game" | "film" | "alt" | "dubstep" | "metal" | "anime" | "hiphop" | "j-pop" | "rock" | "pop" | "electronic" | "classical-orchestral"; export type MapType = "accuracy" | "balanced" | "challenge" | "dancestyle" | "fitness" | "speed" | "tech"; export type MapTag = MapStyle | MapType; diff --git a/src/shared/models/maps/bsm-local-map.interface.ts b/src/shared/models/maps/bsm-local-map.interface.ts index 10bb8048a..eb32c4ecb 100644 --- a/src/shared/models/maps/bsm-local-map.interface.ts +++ b/src/shared/models/maps/bsm-local-map.interface.ts @@ -1,12 +1,12 @@ -import { BsvMapDetail } from "./beat-saver.model"; import { RawMapInfoData } from "./raw-map.model"; +import { SongDetails } from "./song-details-cache.model"; export interface BsmLocalMap { hash: string; coverUrl: string; songUrl: string; rawInfo: RawMapInfoData; - bsaverInfo?: BsvMapDetail; + songDetails?: SongDetails; path: string; } diff --git a/src/shared/models/maps/index.ts b/src/shared/models/maps/index.ts index fe3e504ac..1c3a086a8 100644 --- a/src/shared/models/maps/index.ts +++ b/src/shared/models/maps/index.ts @@ -1,2 +1,3 @@ export { RawMapInfoData, RawMapDifficulty, RawDifficultySet } from "./raw-map.model"; export { BsvInstant, BsvMapDetail, BsvMapDetailMetadata, BsvMapDifficulty, BsvMapParitySummary, BsvMapStats, BsvMapTestplay, BsvMapVersion, BsvUserDetail } from "./beat-saver.model"; +export { SongDetailsCache, SongDetails, Difficulty, MapDetailMetadata, Uploader, SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache.model"; diff --git a/src/shared/models/maps/raw-map.model.ts b/src/shared/models/maps/raw-map.model.ts index 8056233b8..f07c4ef42 100644 --- a/src/shared/models/maps/raw-map.model.ts +++ b/src/shared/models/maps/raw-map.model.ts @@ -1,4 +1,4 @@ -import { BsvMapCharacteristic, BsvMapDifficultyType } from "./beat-saver.model"; +import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache.model"; export interface RawMapInfoData { _version: string; @@ -21,12 +21,12 @@ export interface RawMapInfoData { } export interface RawDifficultySet { - _beatmapCharacteristicName: BsvMapCharacteristic; + _beatmapCharacteristicName: SongDetailDiffCharactertistic; _difficultyBeatmaps: RawMapDifficulty[]; } export interface RawMapDifficulty { - _difficulty: BsvMapDifficultyType; + _difficulty: SongDiffName; _difficultyRank: string; _beatmapFilename: string; _noteJumpMovementSpeed: number; diff --git a/src/shared/models/maps/song-details-cache.model.ts b/src/shared/models/maps/song-details-cache.model.ts new file mode 100644 index 000000000..9198f10c6 --- /dev/null +++ b/src/shared/models/maps/song-details-cache.model.ts @@ -0,0 +1,82 @@ + + +export interface SongDetailsCache { + songs: SongDetails[]; + lastUpdated: number; // int64 in protobuf is represented as number in TypeScript, but consider using BigInt for precise representation + total: number; // uint32 +} + +export interface SongDetails { + id: string; + hash: string; + name: string; + metadata: MapDetailMetadata; + uploader: Uploader; + uploadedAt: number; // int32 + tags: string[]; + bpm: number; // float + ranked: boolean; + qualified: boolean; + curated: boolean; + rankedBL: boolean; + nominatedBL: boolean; + qualifiedBL: boolean; + upVotes: number; // int32 + downVotes: number; // int32 + downloads: number; // int32 + duration: number; // int32 + automapper: boolean; + difficulties: Difficulty[]; +} + +export interface Difficulty { + difficulty: SongDiffName; + characteristic: SongDetailDiffCharactertistic; + label: string; + stars: number; // float + starsBL: number; // float + njs: number; // float + nps: number; // float + offset: number; // float + chroma: boolean; + cinema: boolean; + me: boolean; + ne: boolean; + bombs: number; // int32 + notes: number; // int32 + obstacles: number; // int32 +} + +export interface Uploader { + name: string; + id: number; // int32 + verified: boolean; +} + +export interface MapDetailMetadata { + bpm: number; // float + duration: number; // int32 + levelAuthorName: string; + songAuthorName: string; + songName: string; + songSubName: string; +} + +export enum SongDiffName { + Easy = "Easy", + Normal = "Normal", + Hard = "Hard", + Expert = "Expert", + ExpertPlus = "ExpertPlus", +} + +export enum SongDetailDiffCharactertistic { + Standard = "Standard", + OneSaber = "OneSaber", + NoArrows = "NoArrows", + Lawless = "Lawless", + Lightshow = "Lightshow", + Legacy = "Legacy", + _90Degree = "90Degree", + _360Degree = "360Degree", +} From 59b04ee33e55ada13a63b6f377512252a35e45c7 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Tue, 20 Feb 2024 14:04:45 +0100 Subject: [PATCH 05/36] nothing --- assets/jsons/bs-versions.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/assets/jsons/bs-versions.json b/assets/jsons/bs-versions.json index 31d15741a..8a2b7fdb9 100644 --- a/assets/jsons/bs-versions.json +++ b/assets/jsons/bs-versions.json @@ -690,5 +690,14 @@ "ReleaseImg": "https://clan.cloudflare.steamstatic.com/images/32055887/925e60f14c6815f7617fde11a894ed20f54a0846.png", "ReleaseDate": "1707745958", "year": "2024" + }, + { + "BSVersion": "1.34.6", + "BSManifest": "4398761375819224126", + "OculusBinaryId": "6682206641879058", + "ReleaseURL": "https://store.steampowered.com/news/app/620980/view/7595953477644376720", + "ReleaseImg": "https://clan.cloudflare.steamstatic.com/images/32055887/e7c75bdfc76d00713adc3fbdf221c9ce8ab3620d.png", + "ReleaseDate": "1708091473", + "year": "2024" } ] \ No newline at end of file From a36ccb0c1369c1cc3bcb22dd3e8e5562fdb9b9b1 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 23 Feb 2024 21:49:29 +0100 Subject: [PATCH 06/36] [feature-107] Fix various bugs and add new features for playlists --- src/main/ipcs/bs-playlist-ipcs.ts | 3 +- src/main/models/json-cache.class.ts | 19 +-- .../local-playlists-manager.service.ts | 53 ++++++-- .../maps/local-maps-manager.service.ts | 8 +- .../maps/song-cache.service.ts | 5 + .../maps/song-details-cache.service.ts | 2 +- src/main/services/window-manager.service.ts | 2 +- src/main/util.ts | 4 +- .../maps-playlists-panel.component.tsx | 4 +- .../maps/local-maps-list-panel.component.tsx | 14 +-- .../maps/map-item.component.tsx | 2 +- .../local-playlists-list-panel.component.tsx | 115 +++++++++++++++++- .../playlists/playlist-item.component.tsx | 79 ++++++++++++ .../models-grid.component.tsx | 17 +-- .../nav-bar/bsmanager-icon.component.tsx | 4 +- .../shared/bs-content-loader.component.tsx | 24 ++++ .../components/shared/bsm-image.component.tsx | 18 ++- .../svgs/icons/clock-icon.component.tsx | 9 ++ .../svgs/icons/nps-icon.component.tsx | 10 ++ .../svgs/icons/person-icon.component.tsx | 9 ++ .../svgs/icons/timer-fill.component.tsx | 2 +- .../hooks/use-change-until-equal.hook.ts | 3 - src/renderer/services/audio-player.service.ts | 17 ++- .../services/playlists-manager.service.ts | 6 +- .../models/playlists/local-playlist.models.ts | 13 ++ .../models/playlists/playlist.interface.ts | 7 +- 26 files changed, 379 insertions(+), 70 deletions(-) create mode 100644 src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx create mode 100644 src/renderer/components/shared/bs-content-loader.component.tsx create mode 100644 src/renderer/components/svgs/icons/clock-icon.component.tsx create mode 100644 src/renderer/components/svgs/icons/nps-icon.component.tsx create mode 100644 src/renderer/components/svgs/icons/person-icon.component.tsx create mode 100644 src/shared/models/playlists/local-playlist.models.ts diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index c95fdaa7c..b3284481d 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -1,3 +1,4 @@ +import { BSVersion } from "shared/bs-version.interface"; import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service"; import { IpcService } from "../services/ipc.service"; import { of, throwError } from "rxjs"; @@ -39,7 +40,7 @@ ipc.on("is-playlists-deep-links-enabled", (_, reply) => { } }); -ipc.on("get-version-playlists", (req, reply) => { +ipc.on("get-version-playlists", (req, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); reply(playlists.getVersionPlaylists(req.args)); }); diff --git a/src/main/models/json-cache.class.ts b/src/main/models/json-cache.class.ts index f3a76a9fe..8ce648368 100644 --- a/src/main/models/json-cache.class.ts +++ b/src/main/models/json-cache.class.ts @@ -5,7 +5,7 @@ import { Subject, debounceTime } from "rxjs"; export class JsonCache { - private cache: Record = {}; + private _cache: Record = {}; private readonly setEvent: Subject = new Subject(); public constructor( @@ -22,40 +22,43 @@ export class JsonCache { private load(): void { try { - this.cache = require(this.jsonPath); + this._cache = require(this.jsonPath); } catch (error) { log.warn("Failed to load cache or file cache not exist yet", this.jsonPath, error); } finally { - this.cache ??= {}; + this._cache ??= {}; } } public save(): void { - const res = tryit(() => writeFileSync(this.jsonPath, JSON.stringify(this.cache), { flush: true })); + const res = tryit(() => writeFileSync(this.jsonPath, JSON.stringify(this._cache), { flush: true })); if(res.error){ log.error("Failed to save cache", this.jsonPath, res.error); } } public get(key: string): T { - return this.cache[key]; + return this._cache[key]; } public set(key: string, value: T): void { - this.cache[key] = value; + this._cache[key] = value; this.setEvent?.next(); } public delete(key: string): void { - delete this.cache[key]; + delete this._cache[key]; this.setEvent?.next(); } public clear(): void { - this.cache = {}; + this._cache = {}; this.setEvent?.next(); } + public get cache(): Record { + return this._cache; + } } export type JsonCacheOptions = { diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index f69320751..cc72ef0d8 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -13,6 +13,11 @@ import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, copyFile, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; +import { SongDetailsCacheService } from "./maps/song-details-cache.service"; +import { sToMs } from "shared/helpers/time.helpers"; +import { LocalBPList, LocalBpListSong } from "shared/models/playlists/local-playlist.models"; +import { SongCacheService } from "./maps/song-cache.service"; +import { pathToFileURL } from "url"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -36,6 +41,8 @@ export class LocalPlaylistsManagerService { private readonly fileAssociation: FileAssociationService; private readonly windows: WindowManagerService; private readonly bsaver: BeatSaverService; + private readonly songDetails: SongDetailsCacheService; + private readonly songCache: SongCacheService; private constructor() { this.maps = LocalMapsManagerService.getInstance(); @@ -45,6 +52,9 @@ export class LocalPlaylistsManagerService { this.fileAssociation = FileAssociationService.getInstance(); this.windows = WindowManagerService.getInstance(); this.bsaver = BeatSaverService.getInstance(); + this.songDetails = SongDetailsCacheService.getInstance(); + this.songCache = SongCacheService.getInstance(); + this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => { log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link); @@ -100,34 +110,61 @@ export class LocalPlaylistsManagerService { this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`); } - private getReadBPListOfFolder(folerPath: string): Observable> { - return new Observable>(obs => { + private readLocalBPListsOfFolder(folerPath: string, version?: BSVersion): Observable> { + return new Observable>(obs => { - const progress: Progression = { current: 0, total: 0, data: [] }; + const progress: Progression = { current: 0, total: 0, data: [] }; + const bpLists: LocalBPList[] = []; (async () => { + + await this.songDetails.waitLoaded(sToMs(15)); + if(!pathExistsSync(folerPath)) { throw new Error(`Playlists folder not found ${folerPath}`); } + const mapsFolder = version ? await this.maps.getMapsFolderPath(version) : null; const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist"); progress.total = playlists.length; for (const playlist of playlists) { const playlistPath = path.join(folerPath, playlist); - const playlistContent = await this.readPlaylistFile(playlistPath); - progress.data.push(playlistContent); + const bpList = await this.readPlaylistFile(playlistPath); + + const localBpListSongs = bpList.songs.map(song => { + const localInfos = mapsFolder ? this.songCache.getMapInfoFromHash(song.hash) : null; + const coverPath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._coverImageFilename) : null; + const songFilePath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._songFilename) : null; + return ({ + song, + songDetails: this.songDetails.getSongDetails(song.hash), + coverUrl: (coverPath && pathExistsSync(coverPath)) ? pathToFileURL(coverPath).href : null, + songUrl: (songFilePath && pathExistsSync(songFilePath)) ? pathToFileURL(songFilePath).href : null, + } as LocalBpListSong) + }); + const localBpList: LocalBPList = { ...bpList, path: playlistPath, songs: localBpListSongs }; + bpLists.push(localBpList); progress.current += 1; obs.next(progress); } + + progress.data = bpLists; + obs.next(progress); })().catch(err => obs.error(err)).finally(() => obs.complete()); }); } - public getVersionPlaylists(version: BSVersion): Observable> { - return new Observable>(obs => { + public getVersionPlaylists(version: BSVersion): Observable> { + return new Observable>(obs => { this.getPlaylistsFolder(version) - .then(folder => this.getReadBPListOfFolder(folder).subscribe(obs)) + .then(folder => this.readLocalBPListsOfFolder(folder, version)) + .then(progress$ => lastValueFrom(progress$.pipe( + tap(progress => obs.next({...progress, data: []})), + ))) + .then(res => obs.next(res)) + .catch(err => obs.error(err)) + .finally(() => obs.complete()); }); } diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index d3172b6e4..21bf9fcae 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -25,6 +25,7 @@ import { IpcService } from "../../ipc.service"; import { SongDetailsCacheService } from "./song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; import { SongCacheService } from "./song-cache.service"; +import { pathToFileURL } from "url"; export class LocalMapsManagerService { private static instance: LocalMapsManagerService; @@ -38,6 +39,7 @@ export class LocalMapsManagerService { public static readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data"; public static readonly CUSTOM_LEVELS_FOLDER = "CustomLevels"; + public static readonly RELATIVE_MAPS_FOLDER = path.join(LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER); public static readonly SHARED_MAPS_FOLDER = "SharedMaps"; private readonly DEEP_LINKS = { @@ -81,7 +83,7 @@ export class LocalMapsManagerService { public async getMapsFolderPath(version?: BSVersion): Promise { if (version) { - return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER); + return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.RELATIVE_MAPS_FOLDER); } const sharedMapsPath = path.join(await this.installLocation.sharedContentPath(), LocalMapsManagerService.SHARED_MAPS_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER); if (!(await pathExist(sharedMapsPath))) { @@ -117,8 +119,8 @@ export class LocalMapsManagerService { private async loadMapInfoFromPath(mapPath: string): Promise { const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => { - const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href; - const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href; + const coverUrl = pathToFileURL(path.join(mapPath, rawInfo._coverImageFilename)).href; + const songUrl = pathToFileURL(path.join(mapPath, rawInfo._songFilename)).href; return { rawInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) } as BsmLocalMap; }; diff --git a/src/main/services/additional-content/maps/song-cache.service.ts b/src/main/services/additional-content/maps/song-cache.service.ts index 486193514..fffac6fca 100644 --- a/src/main/services/additional-content/maps/song-cache.service.ts +++ b/src/main/services/additional-content/maps/song-cache.service.ts @@ -26,6 +26,11 @@ export class SongCacheService { return this.rawInfosCache.get(dirname); } + public getMapInfoFromHash(hash: string): { path: string, info: CachedRawInfoWithHash } | undefined { + const res = Object.entries(this.rawInfosCache.cache).find(([, info]) => info.hash === hash); + return res ? { path: res[0], info: res[1] } : undefined; + } + public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void { this.rawInfosCache.set(dirname, info); } diff --git a/src/main/services/additional-content/maps/song-details-cache.service.ts b/src/main/services/additional-content/maps/song-details-cache.service.ts index ceed522ee..f353cf19f 100644 --- a/src/main/services/additional-content/maps/song-details-cache.service.ts +++ b/src/main/services/additional-content/maps/song-details-cache.service.ts @@ -160,7 +160,7 @@ export class SongDetailsCacheService { })))); } - public getSongDetails(hash: string): any { + public getSongDetails(hash: string): SongDetails { return this.songDetailsCache[hash.toLocaleLowerCase()]; } diff --git a/src/main/services/window-manager.service.ts b/src/main/services/window-manager.service.ts index 0450eb404..50495cb26 100644 --- a/src/main/services/window-manager.service.ts +++ b/src/main/services/window-manager.service.ts @@ -47,7 +47,7 @@ export class WindowManagerService { if(isValidUrl(url)){ shell.openExternal(url); } - + return { action: "deny"} }); diff --git a/src/main/util.ts b/src/main/util.ts index c9dac0683..f7857f7bb 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -1,5 +1,5 @@ /* eslint import/prefer-default-export: off, import/no-mutable-exports: off */ -import { URL } from "url"; +import { URL, pathToFileURL } from "url"; import path from "path"; export let resolveHtmlPath: (htmlFileName: string) => string; @@ -12,6 +12,6 @@ if (process.env.NODE_ENV === "development") { }; } else { resolveHtmlPath = (htmlFileName: string) => { - return `file://${path.resolve(__dirname, "../renderer/", htmlFileName)}`; + return pathToFileURL(path.resolve(__dirname, "../renderer/", htmlFileName)).href; }; } diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index afb367c6f..01f14efc7 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -123,8 +123,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { ]} > <> - - + + diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 0b9d12a27..981b169a4 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -9,15 +9,15 @@ import { VariableSizeList } from "react-window"; import { MapsRow } from "./maps-row.component"; import { debounceTime, last, tap } from "rxjs/operators"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -import BeatWaitingImg from "../../../../../assets/images/apngs/beat-waiting.png"; import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; import { BsmImage } from "../../shared/bsm-image.component"; import { BsmButton } from "../../shared/bsm-button.component"; -import TextProgressBar from "../../progress-bar/text-progress-bar.component"; import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; +import { useConstant } from "renderer/hooks/use-constant.hook"; +import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; type Props = { version: BSVersion; @@ -28,7 +28,7 @@ type Props = { isActive?: boolean; }; -export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }: Props, forwardRef) => { +export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }, forwardRef) => { const mapsManager = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); @@ -42,7 +42,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); const [linked, setLinked] = useState(false); - const [loadPercent$] = useState(new BehaviorSubject(0)); + const loadPercent$ = useConstant(() => new BehaviorSubject(0)); useImperativeHandle( forwardRef, @@ -372,11 +372,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear if (!maps) { return (
    -
    - - {t("modals.download-maps.loading-maps")} - -
    +
    ); } diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 4afd25955..b94c1d092 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -76,7 +76,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, const previewUrl = mapId ? `https://allpoland.github.io/ArcViewer/?id=${mapId}` : null; const mapUrl = mapId ? `https://beatsaver.com/maps/${mapId}` : null; const authorUrl = autorId ? `https://beatsaver.com/profile/${autorId}` : null; - const createdDate = createdAt ? dateFormat(createdAt, "d mmm yyyy") : null; + const createdDate = createdAt ? dateFormat(createdAt * 1000, "d mmm yyyy") : null; const likesText = likes ? Intl.NumberFormat(undefined, { notation: "compact" }).format(likes).split(" ").join("") : null; const durationText = (() => { diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index 63dd7fcd0..32df4cc80 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -1,8 +1,16 @@ +import { forwardRef, useState } from "react"; +import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; +import { useConstant } from "renderer/hooks/use-constant.hook"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; +import { useService } from "renderer/hooks/use-service.hook"; +import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; +import { BehaviorSubject, finalize, lastValueFrom, map, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; +import { LocalBPList } from "shared/models/playlists/local-playlist.models"; +import { PlaylistItem } from "./playlist-item.component"; type Props = { version: BSVersion; @@ -11,19 +19,116 @@ type Props = { isActive?: boolean; }; -export function LocalPlaylistsListPanel({ version, className, isActive, linkedState }: Props) { +export const LocalPlaylistsListPanel = forwardRef(({ version, className, isActive, linkedState }, forwardedRef) => { + + const playlistService = useService(PlaylistsManagerService); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); + const [playlistsLoading, setPlaylistsLoading] = useState(false); + const [playlists, setPlaylists] = useState([]); + const loadPercent$ = useConstant(() => new BehaviorSubject(0)); + + const loadPlaylists = (): Promise => { + setPlaylistsLoading(true); + const obs = playlistService.getVersionPlaylists(version).pipe( + tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}), + tap({ next: console.log}), + map(load => load.data), + finalize(() => setPlaylistsLoading(false)) + ); + + return lastValueFrom(obs); + } + + const getNbMappersOfPlaylist = (playlist: LocalBPList) => { + return new Set(playlist.songs.map(s => s.songDetails?.uploader?.id ?? s.song.hash)).size; + }; + + const getDurationOfPlaylist = (playlist: LocalBPList) => { + return playlist.songs.reduce((acc, s) => acc + (s.songDetails?.duration ?? 0), 0); + } + + const getMinNpsOfPlaylist = (playlist: LocalBPList) => { + let min = Infinity; + playlist.songs.forEach(s => { + s.songDetails?.difficulties.forEach(d => { + if(d.nps < min){ + min = d.nps; + } + }) + }); + return min === Infinity ? null : min; + } + + const getMaxNpsOfPlaylist = (playlist: LocalBPList) => { + let max = -Infinity; + playlist.songs.forEach(s => { + s.songDetails?.difficulties.forEach(d => { + if(d.nps > max){ + max = d.nps; + } + }) + }); + return max === -Infinity ? null : max; + } + + const getSongsOfPlaylist = (playlist: LocalBPList) => { + return playlist.songs.map(s => ({ url: s.songUrl ?? `https://cdn.beatsaver.com/${s.song.hash}.mp3`, bpm: s.songDetails?.bpm ?? 0})); + } + useOnUpdate(() => { if(!isActiveOnce){ return noop(); } - // load playlists + loadPlaylists().then(loadedPlaylists => { + setPlaylists(() => loadedPlaylists); + }).catch(() => { + setPlaylists([]) + }).finally(() => { + loadPercent$.next(0); + }); + + }, [isActiveOnce, version]); + + const render = () => { + if(playlistsLoading){ + return ( + + ) + } + + if (playlists.length){ + return ( + <> +
      + {playlists.map(p => + + )} +
    +
    + + ) + } - }, [isActiveOnce]); + return null; + } return ( -
    local-playlists-list-panel.component
    +
    + {render()} +
    ) -} +}); diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx new file mode 100644 index 000000000..75d95f776 --- /dev/null +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -0,0 +1,79 @@ +import { motion } from 'framer-motion'; +import { BsmImage } from 'renderer/components/shared/bsm-image.component'; +import { ClockIcon } from 'renderer/components/svgs/icons/clock-icon.component'; +import { MapIcon } from 'renderer/components/svgs/icons/map-icon.component'; +import { PersonIcon } from 'renderer/components/svgs/icons/person-icon.component'; +import { useThemeColor } from 'renderer/hooks/use-theme-color.hook'; +import dateFormat from 'dateformat'; +import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component'; +import { useService } from 'renderer/hooks/use-service.hook'; +import { AudioPlayerService } from 'renderer/services/audio-player.service'; +import { filter, lastValueFrom, skip, take } from 'rxjs'; + +type Props = { + id: string; + className?: string; + title?: string; + author?: string; + coverBase64?: string; + coverUrl?: string; + nbMaps?: number; + nbMappers?: number; + duration?: number; + minNps?: number; + maxNps?: number; + songs?: { url: string, bpm: number }[]; +} + +export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, songs }: Props) { + + const player = useService(AudioPlayerService); + + const firstColor = useThemeColor("first-color"); + + const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null; + const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null; + const minNpsText = minNps ? Math.round(minNps * 10) / 10 : null; + const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : null; + + const durationText = (() => { + if (!duration) { + return null; + } + const date = new Date(0); + date.setSeconds(duration); + return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss"); + })(); + + const startPlay = async () => { + if (!songs || songs.length === 0) { + return; + } + const playlist = songs.map(s => ({ src: s.url, bpm: s.bpm })); + player.playlist(playlist, 0); + } + + return ( + startPlay()}> +
    + +
    +
    +
    + +
    +
    +

    {title}

    +

    Créé par {author}

    + +
    + { nbMapsText &&
    {nbMapsText}
    } + { nbMappersText &&
    {nbMappersText}
    } + { durationText &&
    {durationText}
    } + { (minNps && maxNps) &&
    {`${minNpsText} - ${maxNpsText}`}
    } +
    + +
    + + ) +} diff --git a/src/renderer/components/models-management/models-grid.component.tsx b/src/renderer/components/models-management/models-grid.component.tsx index 024e4938f..1703bad07 100644 --- a/src/renderer/components/models-management/models-grid.component.tsx +++ b/src/renderer/components/models-management/models-grid.component.tsx @@ -1,5 +1,5 @@ import { BSVersion } from "shared/bs-version.interface"; -import { useRef, forwardRef, useImperativeHandle } from "react"; +import { forwardRef, useImperativeHandle } from "react"; import { MSModelType } from "shared/models/models/model-saber.model"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; @@ -9,9 +9,7 @@ import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface"; import { ModelItem } from "./model-item.component"; import { useBehaviorSubject } from "renderer/hooks/use-behavior-subject.hook"; import { BsmImage } from "../shared/bsm-image.component"; -import BeatWaitingImg from "../../../../assets/images/apngs/beat-waiting.png"; import BeatConflict from "../../../../assets/images/apngs/beat-conflict.png"; -import TextProgressBar from "../progress-bar/text-progress-bar.component"; import { BehaviorSubject, distinctUntilChanged, map, startWith } from "rxjs"; import { BsmButton } from "../shared/bsm-button.component"; import equal from "fast-deep-equal"; @@ -20,6 +18,7 @@ import { MODEL_TYPE_FOLDERS } from "shared/models/models/constants"; import { useService } from "renderer/hooks/use-service.hook"; import { ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { BsContentLoader } from "../shared/bs-content-loader.component"; type Props = { className?: string; @@ -30,14 +29,12 @@ type Props = { downloadModels?: () => void; }; -export const ModelsGrid = forwardRef(({ className, version, type, search, active, downloadModels }: Props, forwardRef) => { +export const ModelsGrid = forwardRef(({ className, version, type, search, active, downloadModels }, forwardRef) => { const modelsManager = useService(ModelsManagerService); const modelsDownloader = useService(ModelsDownloaderService); const t = useTranslation(); - const ref = useRef(); - const [models, setModelsLoadObservable, , setModels] = useSwitchableObservable(); const progress$ = useConstant(() => new BehaviorSubject(0)); const [modelsSelected, modelsSelected$] = useBehaviorSubject([]); @@ -199,11 +196,7 @@ export const ModelsGrid = forwardRef(({ className, version, type, search, active const renderContent = () => { if (isLoading) { return ( -
    - - {t("models.panel.grid.loading")} - -
    + ); } @@ -236,7 +229,7 @@ export const ModelsGrid = forwardRef(({ className, version, type, search, active }; return ( -
    +
    {renderContent()}
    ); diff --git a/src/renderer/components/nav-bar/bsmanager-icon.component.tsx b/src/renderer/components/nav-bar/bsmanager-icon.component.tsx index b4e1355d9..1d6ce561a 100644 --- a/src/renderer/components/nav-bar/bsmanager-icon.component.tsx +++ b/src/renderer/components/nav-bar/bsmanager-icon.component.tsx @@ -8,7 +8,7 @@ import { useService } from "renderer/hooks/use-service.hook"; // Thanks to cheddZy for the icon : https://github.com/cheddZy export const BsManagerIcon = memo(({ className }: { className?: string }) => { - + const audioPlayer = useService(AudioPlayerService); const { firstColor, secondColor } = useThemeColor(); @@ -27,6 +27,8 @@ export const BsManagerIcon = memo(({ className }: { className?: string }) => { const clickAction = () => { if (playing) { audioPlayer.pause(); + } else { + audioPlayer.resume(); } }; diff --git a/src/renderer/components/shared/bs-content-loader.component.tsx b/src/renderer/components/shared/bs-content-loader.component.tsx new file mode 100644 index 000000000..00148cd68 --- /dev/null +++ b/src/renderer/components/shared/bs-content-loader.component.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import TextProgressBar from "../progress-bar/text-progress-bar.component"; +import { BsmImage } from "./bsm-image.component"; +import BeatWaitingImg from "../../../../assets/images/apngs/beat-waiting.png"; +import { Observable } from "rxjs"; + +type Props = { + className?: string; + value$: Observable; + text: string; +} + +export function BsContentLoader({className, value$, text}: Props) { + + const t = useTranslation(); + + return ( +
    + + {t(text)} + +
    + ) +} diff --git a/src/renderer/components/shared/bsm-image.component.tsx b/src/renderer/components/shared/bsm-image.component.tsx index bc2459494..bddb09149 100644 --- a/src/renderer/components/shared/bsm-image.component.tsx +++ b/src/renderer/components/shared/bsm-image.component.tsx @@ -1,20 +1,26 @@ -import { CSSProperties, forwardRef, SyntheticEvent, useState } from "react"; +import { ComponentProps, CSSProperties, forwardRef, SyntheticEvent, useState } from "react"; type Props = { className?: string; - image: string; + image?: string; + base64?: string; errorImage?: string; placeholder?: string; loading?: "lazy" | "eager"; style?: CSSProperties; title?: string; - onClick?: (e: MouseEvent) => void; + onClick?: ComponentProps<"img">["onClick"] }; -export const BsmImage = forwardRef(({ className, image, errorImage, placeholder, loading, style, title, onClick }: Props, ref) => { +export const BsmImage = forwardRef(({ className, image, base64, errorImage, placeholder, loading, style, title, onClick }, ref) => { const [isLoaded, setIsLoaded] = useState(false); - image = image || placeholder || errorImage; + const getBase64Url = () => { + if(base64?.startsWith("data:image")){ return base64; } + return base64 ? `data:image/png;base64,${base64}` : undefined; + }; + + const imageSrc = image || getBase64Url() || placeholder || errorImage; const styles: CSSProperties = (() => { return { @@ -34,5 +40,5 @@ export const BsmImage = forwardRef(({ className, image, errorImage, placeholder, setIsLoaded(() => true); }; - return onClick?.(e)} alt=" " decoding="async" />; + return onClick?.(e)} alt=" " decoding="async" />; }); diff --git a/src/renderer/components/svgs/icons/clock-icon.component.tsx b/src/renderer/components/svgs/icons/clock-icon.component.tsx new file mode 100644 index 000000000..8751da3f9 --- /dev/null +++ b/src/renderer/components/svgs/icons/clock-icon.component.tsx @@ -0,0 +1,9 @@ +import { createSvgIcon } from "../svg-icon.type" + +export const ClockIcon = createSvgIcon((props, ref) => { + return ( + + + + ) +}); diff --git a/src/renderer/components/svgs/icons/nps-icon.component.tsx b/src/renderer/components/svgs/icons/nps-icon.component.tsx new file mode 100644 index 000000000..c93f240d3 --- /dev/null +++ b/src/renderer/components/svgs/icons/nps-icon.component.tsx @@ -0,0 +1,10 @@ +import { createSvgIcon } from "../svg-icon.type"; + +export const NpsIcon = createSvgIcon((props, ref) => { + return ( + + + + + ) +}); diff --git a/src/renderer/components/svgs/icons/person-icon.component.tsx b/src/renderer/components/svgs/icons/person-icon.component.tsx new file mode 100644 index 000000000..0cc82c42c --- /dev/null +++ b/src/renderer/components/svgs/icons/person-icon.component.tsx @@ -0,0 +1,9 @@ +import { createSvgIcon } from '../svg-icon.type'; + +export const PersonIcon = createSvgIcon((props, ref) => { + return ( + + + + ) +}); diff --git a/src/renderer/components/svgs/icons/timer-fill.component.tsx b/src/renderer/components/svgs/icons/timer-fill.component.tsx index 37593bd95..8504be588 100644 --- a/src/renderer/components/svgs/icons/timer-fill.component.tsx +++ b/src/renderer/components/svgs/icons/timer-fill.component.tsx @@ -2,7 +2,7 @@ import { CSSProperties } from "react"; export function TimerFillIcon(props: { className?: string; style?: CSSProperties }) { return ( - + ); diff --git a/src/renderer/hooks/use-change-until-equal.hook.ts b/src/renderer/hooks/use-change-until-equal.hook.ts index d20d64a8a..b44bd9e94 100644 --- a/src/renderer/hooks/use-change-until-equal.hook.ts +++ b/src/renderer/hooks/use-change-until-equal.hook.ts @@ -19,10 +19,7 @@ export function useChangeUntilEqual(variableValue: T, { untilEqual const isEqual = equal(variableValue, untilEqualRef.current); - console.log("isEqual", isEqual, variableValue, untilEqualRef.current); - if(!isEqual){ - console.log("setValue", variableValue); return setValue(variableValue); } diff --git a/src/renderer/services/audio-player.service.ts b/src/renderer/services/audio-player.service.ts index ee63aca02..2d7fec72f 100644 --- a/src/renderer/services/audio-player.service.ts +++ b/src/renderer/services/audio-player.service.ts @@ -50,6 +50,15 @@ export class AudioPlayerService { return this.player.play(); } + public playlist(songs: {src: string, bpm: number}[], index: number): void { + this.play(songs[index].src, songs[index].bpm); + this.player.onended = () => { + if (index < songs.length - 1) { + this.playlist(songs, index + 1); + } + }; + } + public pause(): void { this._playing$.next(false); this.player.pause(); @@ -83,7 +92,10 @@ export class AudioPlayerService { } public toggleMute(): void { - this.muted ? this.unmute() : this.mute(); + if(this.muted){ + return this.unmute(); + } + this.mute(); } public get src$(): Observable { @@ -114,6 +126,9 @@ export class AudioPlayerService { public get muted(): boolean { return this.player.muted; } + public get paused(): boolean { + return this.player.paused; + } } interface PlayerVolume { diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index c974cffea..f45ebee38 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -2,6 +2,8 @@ import { BSVersion } from "shared/bs-version.interface"; import { IpcService } from "./ipc.service"; import { Observable, lastValueFrom } from "rxjs"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; +import { Progression } from "main/helpers/fs.helpers"; +import { LocalBPList } from "shared/models/playlists/local-playlist.models"; export class PlaylistsManagerService { private static instance: PlaylistsManagerService; @@ -23,8 +25,8 @@ export class PlaylistsManagerService { this.linker = VersionFolderLinkerService.getInstance(); } - public getVersionPlaylists(version: BSVersion): Promise { - return this.ipc.sendV2("get-version-playlists", version); + public getVersionPlaylists(version: BSVersion): Observable> { + return this.ipc.sendV2("get-version-playlists", { args: version }); } public isDeepLinksEnabled(): Promise { diff --git a/src/shared/models/playlists/local-playlist.models.ts b/src/shared/models/playlists/local-playlist.models.ts new file mode 100644 index 000000000..f69d22eaf --- /dev/null +++ b/src/shared/models/playlists/local-playlist.models.ts @@ -0,0 +1,13 @@ +import { SongDetails } from "../maps"; +import { BPList, PlaylistSong } from "./playlist.interface"; + +export interface LocalBPList extends BPList { + path: string; +} + +export interface LocalBpListSong { + song: PlaylistSong; + songDetails?: SongDetails; + songUrl?: string; + coverUrl?: string; +} diff --git a/src/shared/models/playlists/playlist.interface.ts b/src/shared/models/playlists/playlist.interface.ts index c483eb964..5fde662d7 100644 --- a/src/shared/models/playlists/playlist.interface.ts +++ b/src/shared/models/playlists/playlist.interface.ts @@ -1,13 +1,13 @@ -import { BsvMapDetail } from "../maps"; +import { BsvMapDetail, SongDetails } from "../maps"; import { BsmLocalMap } from "../maps/bsm-local-map.interface"; -export interface BPList { +export interface BPList { playlistTitle: string; playlistAuthor: string; playlistDescription?: string; image: string; customData: unknown; - songs: PlaylistSong[]; + songs: SongType[]; } export interface PlaylistSong { @@ -15,6 +15,7 @@ export interface PlaylistSong { hash: string; songName: string; uploader?: string; + songDetails?: SongDetails; } export interface DownloadPlaylistProgressionData { From 487d0c1f8bd80852a13e9272c95af49bfd3f3df8 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:24:51 +0100 Subject: [PATCH 07/36] [feature-107] Implement playlists linking + remove possibility to play a playlist for now + some refacto --- .eslintrc.js | 3 + .../maps/local-maps-manager.service.ts | 2 +- .../available-version-item.component.tsx | 4 +- .../maps-playlists-panel.component.tsx | 12 +++- .../maps/map-item.component.tsx | 4 +- .../local-playlists-list-panel.component.tsx | 10 +-- .../playlists/playlist-item.component.tsx | 61 +++++++++---------- .../link-playlist-modal.component.tsx | 33 ++++++++++ .../models/link-models-modal.component.tsx | 1 - .../unlink-playlist-modal.component.tsx | 32 ++++++++++ .../model-item.component.tsx | 4 +- .../text-progress-bar.component.tsx | 4 +- .../components/shared/bsm-image.component.tsx | 2 +- .../svgs/icons/folder-open-icon.component.tsx | 11 ++++ src/renderer/components/svgs/svg-icon.type.ts | 11 ++-- src/renderer/hooks/use-state-map.hook.ts | 13 ++++ src/renderer/services/maps-manager.service.ts | 4 +- .../services/playlists-manager.service.ts | 34 +++++++++++ .../services/version-folder-linker.service.ts | 18 ++---- 19 files changed, 192 insertions(+), 71 deletions(-) create mode 100644 src/renderer/components/modal/modal-types/link-playlist-modal.component.tsx create mode 100644 src/renderer/components/modal/modal-types/unlink-playlist-modal.component.tsx create mode 100644 src/renderer/components/svgs/icons/folder-open-icon.component.tsx create mode 100644 src/renderer/hooks/use-state-map.hook.ts diff --git a/.eslintrc.js b/.eslintrc.js index d0de28b5e..019724e65 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -65,6 +65,9 @@ module.exports = { tsconfigRootDir: __dirname, createDefaultProgram: true, }, + globals: { + JSX: true, + } settings: { "import/resolver": { // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index 21bf9fcae..38e75f4ab 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -116,7 +116,7 @@ export class LocalMapsManagerService { return shasum.digest("hex"); } - private async loadMapInfoFromPath(mapPath: string): Promise { + public async loadMapInfoFromPath(mapPath: string): Promise { const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => { const coverUrl = pathToFileURL(path.join(mapPath, rawInfo._coverImageFilename)).href; diff --git a/src/renderer/components/available-versions/available-version-item.component.tsx b/src/renderer/components/available-versions/available-version-item.component.tsx index 605c054d4..2246682b2 100644 --- a/src/renderer/components/available-versions/available-version-item.component.tsx +++ b/src/renderer/components/available-versions/available-version-item.component.tsx @@ -29,8 +29,8 @@ export const AvailableVersionItem = memo(function AvailableVersionItem({version, {version.recommended && ( {t("pages.available-versions.recommended")} )} - - + +

    {version.BSVersion}

    diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 01f14efc7..4a5aaa152 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -68,6 +68,16 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return mapsService.unlinkVersion(version); }; + const handlePlaylistLinkClick = () => { + if(playlistLinkedState === FolderLinkState.Pending || playlistLinkedState === FolderLinkState.Processing){ return Promise.resolve(false); } + + if (playlistLinkedState === FolderLinkState.Unlinked) { + return playlistsService.linkVersion(version); + } + + return playlistsService.unlinkVersion(version); + } + const dropDownItems = ((): DropDownItem[] => { if (tabIndex === 1) { return []; @@ -117,7 +127,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { onClick: () => setTabIndex(1), linkProps: version ? { state: playlistLinkedState, - onClick: () => Promise.resolve(false), + onClick: handlePlaylistLinkClick, } : null, }, ]} diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index b94c1d092..331d1db56 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -178,7 +178,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
    - +
    - +

    diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index 32df4cc80..efd76a7d3 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -11,6 +11,7 @@ import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; import { LocalBPList } from "shared/models/playlists/local-playlist.models"; import { PlaylistItem } from "./playlist-item.component"; +import { useStateMap } from "renderer/hooks/use-state-map.hook"; type Props = { version: BSVersion; @@ -28,12 +29,12 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl const [playlistsLoading, setPlaylistsLoading] = useState(false); const [playlists, setPlaylists] = useState([]); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); + const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); const loadPlaylists = (): Promise => { setPlaylistsLoading(true); const obs = playlistService.getVersionPlaylists(version).pipe( tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}), - tap({ next: console.log}), map(load => load.data), finalize(() => setPlaylistsLoading(false)) ); @@ -73,10 +74,6 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl return max === -Infinity ? null : max; } - const getSongsOfPlaylist = (playlist: LocalBPList) => { - return playlist.songs.map(s => ({ url: s.songUrl ?? `https://cdn.beatsaver.com/${s.song.hash}.mp3`, bpm: s.songDetails?.bpm ?? 0})); - } - useOnUpdate(() => { if(!isActiveOnce){ return noop(); } @@ -89,7 +86,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl loadPercent$.next(0); }); - }, [isActiveOnce, version]); + }, [isActiveOnce, version, linked]); const render = () => { if(playlistsLoading){ @@ -114,7 +111,6 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl duration={getDurationOfPlaylist(p)} maxNps={getMaxNpsOfPlaylist(p)} minNps={getMinNpsOfPlaylist(p)} - songs={getSongsOfPlaylist(p)} /> )} diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index 75d95f776..d549f544f 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -6,9 +6,9 @@ import { PersonIcon } from 'renderer/components/svgs/icons/person-icon.component import { useThemeColor } from 'renderer/hooks/use-theme-color.hook'; import dateFormat from 'dateformat'; import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component'; -import { useService } from 'renderer/hooks/use-service.hook'; -import { AudioPlayerService } from 'renderer/services/audio-player.service'; -import { filter, lastValueFrom, skip, take } from 'rxjs'; +import { GlowEffect } from 'renderer/components/shared/glow-effect.component'; +import { useState } from 'react'; +import { FolderOpenIcon } from 'renderer/components/svgs/icons/folder-open-icon.component'; type Props = { id: string; @@ -22,14 +22,14 @@ type Props = { duration?: number; minNps?: number; maxNps?: number; - songs?: { url: string, bpm: number }[]; + selected?: boolean; } -export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, songs }: Props) { +export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected }: Props) { - const player = useService(AudioPlayerService); + const color = useThemeColor("first-color"); - const firstColor = useThemeColor("first-color"); + const [hovered, setHovered] = useState(false); const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null; const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null; @@ -45,35 +45,32 @@ export function PlaylistItem({ id, className, title, author, coverUrl, coverBase return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss"); })(); - const startPlay = async () => { - if (!songs || songs.length === 0) { - return; - } - const playlist = songs.map(s => ({ src: s.url, bpm: s.bpm })); - player.playlist(playlist, 0); - } - return ( - startPlay()}> -
    - -
    -
    -
    - -
    -
    -

    {title}

    -

    Créé par {author}

    - -
    - { nbMapsText &&
    {nbMapsText}
    } - { nbMappersText &&
    {nbMappersText}
    } - { durationText &&
    {durationText}
    } - { (minNps && maxNps) &&
    {`${minNpsText} - ${maxNpsText}`}
    } + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + +
    +
    + +
    +
    + + +
    +
    +

    {title}

    +

    Créé par {author}

    + +
    + { nbMapsText &&
    {nbMapsText}
    } + { nbMappersText &&
    {nbMappersText}
    } + { durationText &&
    {durationText}
    } + { (minNps && maxNps) &&
    {`${minNpsText} - ${maxNpsText}`}
    } +
    +
    + ) } diff --git a/src/renderer/components/modal/modal-types/link-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/link-playlist-modal.component.tsx new file mode 100644 index 000000000..a0d26f1de --- /dev/null +++ b/src/renderer/components/modal/modal-types/link-playlist-modal.component.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatRunning from "../../../../../assets/images/apngs/beat-running.png"; + +export const LinkPlaylistModal: ModalComponent = ({ resolver }) => { + const t = useTranslation(); + const [keepMaps, setKeepMaps] = useState(true); + + // TODO : Translate + + return ( +
    +

    Lier les playlists

    + +

    La liaison des playlists permet de partager les playlists entre toute les version. Une fois liée, cette version profitera des playlists partagées

    +

    L'ajout et la suppression de playlists sera également partagé

    +
    + + + Conserver les playlists + +
    +
    + resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED, data: keepMaps })} withBar={false} text="Lier les playlists" /> +
    + + ); +}; diff --git a/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx index 88d51f86a..ecaafbc73 100644 --- a/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx @@ -9,7 +9,6 @@ import BeatRunning from "../../../../../../assets/images/apngs/beat-running.png" export const LinkModelsModal: ModalComponent = ({ resolver, data }) => { const t = useTranslation(); - const [keepMaps, setKeepMaps] = useState(true); return ( diff --git a/src/renderer/components/modal/modal-types/unlink-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/unlink-playlist-modal.component.tsx new file mode 100644 index 000000000..34b1a46bc --- /dev/null +++ b/src/renderer/components/modal/modal-types/unlink-playlist-modal.component.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; + +export const UnlinkPlaylistModal: ModalComponent = ({ resolver }) => { + const t = useTranslation(); + const [keepMaps, setKeepMaps] = useState(true); + + // TODO : Translate + + return ( +
    +

    Délier les playlists

    + +

    Attention, délier les playlists ne permettra plus l'utilisation des playlists paratagées pour cette version.

    +
    + + + Conserver les playlists + +
    +
    + resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED, data: keepMaps })} withBar={false} text="Délier les playlists" /> +
    + + ); +}; diff --git a/src/renderer/components/models-management/model-item.component.tsx b/src/renderer/components/models-management/model-item.component.tsx index cbb8555a6..ea0b90fb1 100644 --- a/src/renderer/components/models-management/model-item.component.tsx +++ b/src/renderer/components/models-management/model-item.component.tsx @@ -116,8 +116,8 @@ function ModelItemElement(props: Props) {
    - - + +
    {!props.isDownloading ? ( actionButtons().map((button, index) => ( diff --git a/src/renderer/components/progress-bar/text-progress-bar.component.tsx b/src/renderer/components/progress-bar/text-progress-bar.component.tsx index 81964e870..86d1028ba 100644 --- a/src/renderer/components/progress-bar/text-progress-bar.component.tsx +++ b/src/renderer/components/progress-bar/text-progress-bar.component.tsx @@ -1,6 +1,6 @@ import { CSSProperties } from "react"; import { useObservable } from "renderer/hooks/use-observable.hook"; -import { Observable } from "rxjs"; +import { Observable, map } from "rxjs"; type Props = { value$: Observable; @@ -9,7 +9,7 @@ type Props = { }; export default function TextProgressBar({ value$, className, style }: Props) { - const value = useObservable(() => value$); + const value = useObservable(() => value$.pipe(map(v => Math.round(Number(v))))); const prefix = typeof value === "number" ? "%" : ""; diff --git a/src/renderer/components/shared/bsm-image.component.tsx b/src/renderer/components/shared/bsm-image.component.tsx index bddb09149..fc316b40c 100644 --- a/src/renderer/components/shared/bsm-image.component.tsx +++ b/src/renderer/components/shared/bsm-image.component.tsx @@ -40,5 +40,5 @@ export const BsmImage = forwardRef(({ className, image, setIsLoaded(() => true); }; - return onClick?.(e)} alt=" " decoding="async" />; + return onClick?.(e)} alt=" " decoding="async" />; }); diff --git a/src/renderer/components/svgs/icons/folder-open-icon.component.tsx b/src/renderer/components/svgs/icons/folder-open-icon.component.tsx new file mode 100644 index 000000000..6107000c3 --- /dev/null +++ b/src/renderer/components/svgs/icons/folder-open-icon.component.tsx @@ -0,0 +1,11 @@ +import { createSvgIcon } from "../svg-icon.type"; + +export const FolderOpenIcon = createSvgIcon((props, ref) => { + + return ( + + + + ); + +}); diff --git a/src/renderer/components/svgs/svg-icon.type.ts b/src/renderer/components/svgs/svg-icon.type.ts index 67e581933..380c07c24 100644 --- a/src/renderer/components/svgs/svg-icon.type.ts +++ b/src/renderer/components/svgs/svg-icon.type.ts @@ -1,10 +1,11 @@ -// These types and functions are meant replace the current clunky SVG system (the one with `bsm-icon.component.tsx`) +// These types and functions are meant replace the current clunky SVG system (the one with `bsm-icon.component.tsx`) -import { ForwardedRef, forwardRef } from "react" +import { ForwardRefExoticComponent, ForwardedRef, RefAttributes, SVGProps, forwardRef } from "react" -export type SvgIcon = React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; -export type SvgRenderFunction = (props: React.SVGProps, ref: ForwardedRef) => JSX.Element; + +export type SvgIcon = ForwardRefExoticComponent, "ref"> & RefAttributes>; +export type SvgRenderFunction = (props: SVGProps, ref: ForwardedRef) => JSX.Element; export function createSvgIcon(render: SvgRenderFunction): SvgIcon { return forwardRef(render); -} \ No newline at end of file +} diff --git a/src/renderer/hooks/use-state-map.hook.ts b/src/renderer/hooks/use-state-map.hook.ts new file mode 100644 index 000000000..347d95e38 --- /dev/null +++ b/src/renderer/hooks/use-state-map.hook.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +export function useStateMap(value: T, map: (value: T, mappedValue: U) => U, defaultValue?: U): U { + + const [mappedValue, setMappedValue] = useState(defaultValue ?? map(value, defaultValue)); + + useEffect(() => { + setMappedValue(map(value, mappedValue)); + }, [value, map]); + + return mappedValue; + +} diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index e973b8595..8aeed33f3 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -13,7 +13,7 @@ import { ConfigurationService } from "./configuration.service"; import { ArchiveProgress } from "shared/models/archive.interface"; import { map, last, catchError } from "rxjs/operators"; import { ProgressionInterface } from "shared/models/progress-bar"; -import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionType } from "./version-folder-linker.service"; +import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; export class MapsManagerService { private static instance: MapsManagerService; @@ -64,7 +64,6 @@ export class MapsManagerService { return this.linker.linkVersionFolder({ version, - type: VersionLinkerActionType.Link, relativeFolder: MapsManagerService.RELATIVE_MAPS_FOLDER, options: { keepContents: !!modalRes.data }, }); @@ -79,7 +78,6 @@ export class MapsManagerService { return this.linker.unlinkVersionFolder({ version, - type: VersionLinkerActionType.Unlink, relativeFolder: MapsManagerService.RELATIVE_MAPS_FOLDER, options: { keepContents: !!modalRes.data }, }); diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index f45ebee38..9aca80cc5 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -4,6 +4,10 @@ import { Observable, lastValueFrom } from "rxjs"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; import { Progression } from "main/helpers/fs.helpers"; import { LocalBPList } from "shared/models/playlists/local-playlist.models"; +import { ModalExitCode, ModalService } from "./modale.service"; +import { LinkMapsModal } from "renderer/components/modal/modal-types/link-maps-modal.component"; +import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component"; +import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component"; export class PlaylistsManagerService { private static instance: PlaylistsManagerService; @@ -19,16 +23,46 @@ export class PlaylistsManagerService { private readonly ipc: IpcService; private readonly linker: VersionFolderLinkerService; + private readonly modal: ModalService; private constructor() { this.ipc = IpcService.getInstance(); this.linker = VersionFolderLinkerService.getInstance(); + this.modal = ModalService.getInstance(); } public getVersionPlaylists(version: BSVersion): Observable> { return this.ipc.sendV2("get-version-playlists", { args: version }); } + public async linkVersion(version: BSVersion): Promise { + const modalRes = await this.modal.openModal(LinkPlaylistModal); + + if (modalRes.exitCode !== ModalExitCode.COMPLETED) { + return Promise.resolve(false); + } + + return this.linker.linkVersionFolder({ + version, + relativeFolder: PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER, + options: { keepContents: !!modalRes.data }, + }); + } + + public async unlinkVersion(version: BSVersion): Promise { + const modalRes = await this.modal.openModal(UnlinkPlaylistModal); + + if (modalRes.exitCode !== ModalExitCode.COMPLETED) { + return Promise.resolve(false); + } + + return this.linker.unlinkVersionFolder({ + version, + relativeFolder: PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER, + options: { keepContents: !!modalRes.data }, + }); + } + public isDeepLinksEnabled(): Promise { return lastValueFrom(this.ipc.sendV2("is-playlists-deep-links-enabled")); } diff --git a/src/renderer/services/version-folder-linker.service.ts b/src/renderer/services/version-folder-linker.service.ts index 936de9d68..dcc2df4ab 100644 --- a/src/renderer/services/version-folder-linker.service.ts +++ b/src/renderer/services/version-folder-linker.service.ts @@ -82,7 +82,7 @@ export class VersionFolderLinkerService { this.onVersionFolderLinked(callBack); }); - this._queue$.next([...this._queue$.value, action]); + this._queue$.next([...this._queue$.value, {...action, type: VersionLinkerActionType.Link}]); return promise; } @@ -100,7 +100,7 @@ export class VersionFolderLinkerService { this.onVersionFolderUnlinked(callBack); }); - this._queue$.next([...this._queue$.value, action]); + this._queue$.next([...this._queue$.value, {...action, type: VersionLinkerActionType.Unlink}]); return promise; } @@ -136,7 +136,7 @@ export class VersionFolderLinkerService { if(currentAction && equal(currentAction.version, version) && currentAction.relativeFolder === relativeFolder) { return of(FolderLinkState.Processing) } - + if(queue.some(action => equal(action.version, version) && action.relativeFolder === relativeFolder)) { return of(FolderLinkState.Pending); } @@ -146,7 +146,7 @@ export class VersionFolderLinkerService { ); }), distinctUntilChanged(), - shareReplay(1) + shareReplay(1) ); } @@ -179,14 +179,8 @@ export interface VersionLinkerAction { options?: LinkOptions; } -export interface VersionLinkFolderAction extends VersionLinkerAction { - type: VersionLinkerActionType.Link; -} - -export interface VersionUnlinkFolderAction extends VersionLinkerAction { - type: VersionLinkerActionType.Unlink; - options?: UnlinkOptions; -} +export type VersionLinkFolderAction = Omit; +export type VersionUnlinkFolderAction = Omit; export type VersionLinkerActionListener = (action: VersionLinkerAction, linked: boolean) => void; From 08bdc03745a104df964cf306e006244b17237a55 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Tue, 27 Feb 2024 22:52:18 +0100 Subject: [PATCH 08/36] [feature-107] Make playlist discoverable in shared tab + quik fix miss handle params in html file url --- release/app/package.json | 2 +- .../local-playlists-manager.service.ts | 23 ++++++++++--------- src/main/util.ts | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/release/app/package.json b/release/app/package.json index 264d7c0d4..242c90e18 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "bs-manager", - "version": "1.4.4", + "version": "1.5.0", "description": "BSManager", "main": "./dist/main/main.js", "author": { diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index cc72ef0d8..c51c75f1b 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -10,14 +10,15 @@ import { WindowManagerService } from "../window-manager.service"; import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; -import { copy, copyFile, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; -import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; +import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; +import { Progression, pathExist } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; import { LocalBPList, LocalBpListSong } from "shared/models/playlists/local-playlist.models"; import { SongCacheService } from "./maps/song-cache.service"; import { pathToFileURL } from "url"; +import { InstallationLocationService } from "../installation-location.service"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -43,6 +44,7 @@ export class LocalPlaylistsManagerService { private readonly bsaver: BeatSaverService; private readonly songDetails: SongDetailsCacheService; private readonly songCache: SongCacheService; + private readonly bsmFs: InstallationLocationService; private constructor() { this.maps = LocalMapsManagerService.getInstance(); @@ -54,6 +56,7 @@ export class LocalPlaylistsManagerService { this.bsaver = BeatSaverService.getInstance(); this.songDetails = SongDetailsCacheService.getInstance(); this.songCache = SongCacheService.getInstance(); + this.bsmFs = InstallationLocationService.getInstance(); this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => { @@ -70,17 +73,12 @@ export class LocalPlaylistsManagerService { } private async getPlaylistsFolder(version?: BSVersion) { - if (!version) { - throw new Error("Playlists are not available to be linked yet"); - } - - const versionFolder = await this.versions.getVersionPath(version); - - const folder = path.join(versionFolder, this.PLAYLISTS_FOLDER); + const rootPath = version ? await this.versions.getVersionPath(version) : await this.bsmFs.sharedContentPath(); + const fullPath = path.join(rootPath, this.PLAYLISTS_FOLDER); - await ensureFolderExist(folder); + await ensureDir(fullPath); - return folder; + return fullPath; } private async installBPListFile(bslistSource: string, version: BSVersion): Promise { @@ -156,6 +154,9 @@ export class LocalPlaylistsManagerService { } public getVersionPlaylists(version: BSVersion): Observable> { + + console.log("getVersionPlaylists", version); + return new Observable>(obs => { this.getPlaylistsFolder(version) .then(folder => this.readLocalBPListsOfFolder(folder, version)) diff --git a/src/main/util.ts b/src/main/util.ts index f7857f7bb..c9dac0683 100644 --- a/src/main/util.ts +++ b/src/main/util.ts @@ -1,5 +1,5 @@ /* eslint import/prefer-default-export: off, import/no-mutable-exports: off */ -import { URL, pathToFileURL } from "url"; +import { URL } from "url"; import path from "path"; export let resolveHtmlPath: (htmlFileName: string) => string; @@ -12,6 +12,6 @@ if (process.env.NODE_ENV === "development") { }; } else { resolveHtmlPath = (htmlFileName: string) => { - return pathToFileURL(path.resolve(__dirname, "../renderer/", htmlFileName)).href; + return `file://${path.resolve(__dirname, "../renderer/", htmlFileName)}`; }; } From 1e6f051f59f0c7d159f802dad9b85a6e141fcfe4 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Sat, 2 Mar 2024 17:50:08 +0100 Subject: [PATCH 09/36] [feature-107] can now list maps of playlist + lot of other things --- src/main/ipcs/bs-playlist-ipcs.ts | 4 +- .../local-playlists-manager.service.ts | 71 ++++++----- .../maps-playlists-panel.component.tsx | 17 ++- .../maps/local-maps-list-panel.component.tsx | 24 ++-- .../maps/map-item.component.tsx | 2 +- .../maps/maps-row.component.tsx | 60 ++++++---- .../local-playlists-list-panel.component.tsx | 113 +++++++++--------- .../playlists/playlist-item.component.tsx | 30 +++-- .../login-to-steam-modal.component.tsx | 10 +- .../steam-mobile-approve-modal.component.tsx | 6 +- .../changelog-modal.component.tsx | 2 +- ...create-launch-shortcut-modal.component.tsx | 10 +- .../delete-maps-modal.component.tsx | 4 +- .../download-maps-modal.component.tsx | 37 +++--- .../edit-version-modal.component.tsx | 2 +- .../models/delete-models-modal.component.tsx | 2 +- .../download-models-modal.component.tsx | 2 +- .../models/link-models-modal.component.tsx | 2 +- .../models/unlink-models-modal.component.tsx | 2 +- ...local-playlist-details-modal.component.tsx | 57 +++++++++ .../playlist-details-template.component.tsx | 66 ++++++++++ .../share-folders-modal.component.tsx | 2 +- .../uninstall-all-mods-modal.component.tsx | 2 +- .../uninstall-mod-modal.component.tsx | 2 +- .../modal-types/uninstall-modal.component.tsx | 2 +- .../components/modal/modal.component.tsx | 69 ++++++----- .../bs-content-tab-panel.component.tsx | 8 +- ...theme-color-gradient-spliter.component.tsx | 11 ++ .../svgs/icons/search-icon.component.tsx | 8 +- .../pages/version-viewer.component.tsx | 6 +- src/renderer/services/auto-updater.service.ts | 2 +- .../services/bs-mods-manager.service.ts | 4 +- .../steam-downloader.service.ts | 16 +-- .../services/bs-version-manager.service.ts | 6 +- .../services/maps-downloader.service.ts | 2 +- src/renderer/services/maps-manager.service.ts | 2 +- src/renderer/services/modale.service.ts | 46 +++---- .../models-downloader.service.ts | 2 +- .../models-manager.service.ts | 8 +- .../services/playlists-manager.service.ts | 7 +- src/renderer/windows/App.tsx | 4 + src/shared/models/maps/beat-saver.model.ts | 6 +- .../models/playlists/local-playlist.models.ts | 19 +-- .../models/playlists/playlist.interface.ts | 6 +- 44 files changed, 472 insertions(+), 291 deletions(-) create mode 100644 src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx create mode 100644 src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx create mode 100644 src/renderer/components/shared/theme-color-gradient-spliter.component.tsx diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index b3284481d..3d76da3d7 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -40,7 +40,7 @@ ipc.on("is-playlists-deep-links-enabled", (_, reply) => { } }); -ipc.on("get-version-playlists", (req, reply) => { +ipc.on("get-version-playlists-details", (req, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); - reply(playlists.getVersionPlaylists(req.args)); + reply(playlists.getVersionPlaylistsDetails(req.args)); }); diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index c51c75f1b..6cc43af01 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -15,7 +15,7 @@ import { Progression, pathExist } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; -import { LocalBPList, LocalBpListSong } from "shared/models/playlists/local-playlist.models"; +import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { SongCacheService } from "./maps/song-cache.service"; import { pathToFileURL } from "url"; import { InstallationLocationService } from "../installation-location.service"; @@ -108,7 +108,7 @@ export class LocalPlaylistsManagerService { this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`); } - private readLocalBPListsOfFolder(folerPath: string, version?: BSVersion): Observable> { + private getLocalBPListsOfFolder(folerPath: string): Observable> { return new Observable>(obs => { const progress: Progression = { current: 0, total: 0, data: [] }; @@ -116,13 +116,10 @@ export class LocalPlaylistsManagerService { (async () => { - await this.songDetails.waitLoaded(sToMs(15)); - if(!pathExistsSync(folerPath)) { throw new Error(`Playlists folder not found ${folerPath}`); } - const mapsFolder = version ? await this.maps.getMapsFolderPath(version) : null; const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist"); progress.total = playlists.length; @@ -130,18 +127,8 @@ export class LocalPlaylistsManagerService { const playlistPath = path.join(folerPath, playlist); const bpList = await this.readPlaylistFile(playlistPath); - const localBpListSongs = bpList.songs.map(song => { - const localInfos = mapsFolder ? this.songCache.getMapInfoFromHash(song.hash) : null; - const coverPath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._coverImageFilename) : null; - const songFilePath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._songFilename) : null; - return ({ - song, - songDetails: this.songDetails.getSongDetails(song.hash), - coverUrl: (coverPath && pathExistsSync(coverPath)) ? pathToFileURL(coverPath).href : null, - songUrl: (songFilePath && pathExistsSync(songFilePath)) ? pathToFileURL(songFilePath).href : null, - } as LocalBpListSong) - }); - const localBpList: LocalBPList = { ...bpList, path: playlistPath, songs: localBpListSongs }; + + const localBpList: LocalBPList = { ...bpList, path: playlistPath }; bpLists.push(localBpList); progress.current += 1; obs.next(progress); @@ -153,18 +140,48 @@ export class LocalPlaylistsManagerService { }); } - public getVersionPlaylists(version: BSVersion): Observable> { + public getVersionPlaylistsDetails(version: BSVersion): Observable> { - console.log("getVersionPlaylists", version); + return new Observable>(obs => { + (async () => { - return new Observable>(obs => { - this.getPlaylistsFolder(version) - .then(folder => this.readLocalBPListsOfFolder(folder, version)) - .then(progress$ => lastValueFrom(progress$.pipe( - tap(progress => obs.next({...progress, data: []})), - ))) - .then(res => obs.next(res)) - .catch(err => obs.error(err)) + const folder = await this.getPlaylistsFolder(version); + const progress$ = this.getLocalBPListsOfFolder(folder); + + const localBPListsRes = (await lastValueFrom(progress$.pipe(tap({ next: progress => obs.next({...progress, data: []}) })))); + + await this.songDetails.waitLoaded(sToMs(15)); + + const tryExtractPlaylistId = (url: string) => { + const regex = /\/id\/(\d+)\/download/; + const match = url.match(regex); + return match ? Number(match[1]) : undefined; + } + + const bpListsDetails: LocalBPListsDetails[] = []; + for(const bpList of localBPListsRes.data){ + + const bpListDetails: LocalBPListsDetails = { + ...bpList, + nbMaps: bpList.songs?.length ?? 0, + id: bpList.customData?.syncURL ? tryExtractPlaylistId(bpList.customData.syncURL) : undefined + } + + const songsDetails = bpList.songs?.map(s => this.songDetails.getSongDetails(s.hash)); + + if(songsDetails){ + bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0); + bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size; + bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0)))); + bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0)))); + } + + bpListsDetails.push(bpListDetails); + } + + obs.next({...localBPListsRes, data: bpListsDetails}); + + })().catch(err => obs.error(err)) .finally(() => obs.complete()); }); } diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 4a5aaa152..8073f321d 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { Dispatch, SetStateAction, createContext, useMemo, useRef, useState } from "react"; import { BSVersion } from "shared/bs-version.interface"; import { LocalMapsListPanel } from "./maps/local-maps-list-panel.component"; import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component"; @@ -14,15 +14,19 @@ import { BsmButton } from "../shared/bsm-button.component"; import { MapIcon } from "../svgs/icons/map-icon.component"; import { PlaylistIcon } from "../svgs/icons/playlist-icon.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; -import { of } from "rxjs"; +import { BehaviorSubject, Observable, of } from "rxjs"; import { LocalPlaylistsListPanel } from "./playlists/local-playlists-list-panel.component"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; +import { useConstant } from "renderer/hooks/use-constant.hook"; type Props = { version?: BSVersion; isActive?: boolean; }; +export const InstalledMapsContext = createContext<{ maps$?: BehaviorSubject; setMaps: (maps: BsmLocalMap[]) => void }>(null); + export function MapsPlaylistsPanel({ version, isActive }: Props) { const mapsService = useService(MapsManagerService); @@ -32,6 +36,9 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const t = useTranslation(); const [tabIndex, setTabIndex] = useState(0); + const maps$ = useConstant(() => new BehaviorSubject(undefined)); + const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$) })); + const mapsRef = useRef(); const [mapFilter, setMapFilter] = useState({}); const [mapSearch, setMapSearch] = useState(""); @@ -55,7 +62,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { }; const handleMapsAddClick = () => { - mapsDownloader.openDownloadMapModal(version, mapsRef.current.getMaps?.()); + mapsDownloader.openDownloadMapModal(version, maps$.value); }; const handleMapsLinkClick = () => { @@ -132,10 +139,10 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { }, ]} > - <> + - +
    ); diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 981b169a4..567401d98 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -1,6 +1,6 @@ import { MapsManagerService } from "renderer/services/maps-manager.service"; import { BSVersion } from "shared/bs-version.interface"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { Subscription, BehaviorSubject } from "rxjs"; import { MapFilter, MapTag } from "shared/models/maps/beat-saver.model"; @@ -18,6 +18,8 @@ import { FolderLinkState } from "renderer/services/version-folder-linker.service import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; +import { InstalledMapsContext } from "../maps-playlists-panel.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; type Props = { version: BSVersion; @@ -33,8 +35,11 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa const mapsDownloader = useService(MapsDownloaderService); const t = useTranslation(); + const ref = useRef(null); - const [maps, setMaps] = useState(null); + + const {maps$, setMaps} = useContext(InstalledMapsContext); + const maps = useObservable(() => maps$, undefined); const [subs] = useState([]); const [selectedMaps$] = useState(new BehaviorSubject([])); const [itemPerRow, setItemPerRow] = useState(2); @@ -59,10 +64,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa }, exportMaps() { mapsManager.exportMaps(version, selectedMaps$.value); - }, - getMaps() { - return maps; - }, + } }), [selectedMaps$.value, maps, version] ); @@ -79,12 +81,12 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa if (targerVersion !== version) { return; } - setMaps(maps => (maps ? [map, ...maps] : [map])); + setMaps((maps ? [map, ...maps] : [map])); }); } return () => { - setMaps(() => null); + setMaps(null); loadPercent$.next(0); subs.forEach(s => s.unsubscribe()); mapsDownloader.removeOnMapDownloadedListener(loadMaps); @@ -122,7 +124,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa }, [isActiveOnce, itemPerRow]); const loadMaps = () => { - setMaps(() => null); + setMaps(null); loadPercent$.next(0); const loadMapsObs$ = mapsManager.getMaps(version); @@ -134,7 +136,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa last() ) .subscribe({ - next: progress => setMaps(() => progress.maps), + next: progress => setMaps(progress.maps), complete: () => loadPercent$.next(0), }) ); @@ -142,7 +144,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa const removeMapsFromList = (mapsToRemove: BsmLocalMap[]) => { const filtredMaps = maps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash)); - setMaps(() => filtredMaps); + setMaps(filtredMaps); const filtredSelectedMaps = selectedMaps$.value.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash)); selectedMaps$.next(filtredSelectedMaps); diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 331d1db56..48ba662be 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -155,7 +155,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, }; return ( - setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}> + setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}> {(diffsPanelHovered || bottomBarHovered) && ( diff --git a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx index 580ae2a35..555b64525 100644 --- a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx @@ -5,7 +5,8 @@ import { BehaviorSubject } from "rxjs"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { ParsedMapDiff, MapItem } from "./map-item.component"; import equal from "fast-deep-equal/es6"; -import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache.model"; +import { SongDetailDiffCharactertistic, SongDetails } from "shared/models/maps/song-details-cache.model"; +import { BsvMapDetail, RawMapInfoData } from "shared/models/maps"; type Props = { maps: BsmLocalMap[]; @@ -21,29 +22,6 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel distinctUntilChanged(equal), ), []); - const extractMapDiffs = (map: BsmLocalMap): Map => { - const res = new Map(); - if (map.songDetails?.difficulties) { - map.songDetails?.difficulties.forEach(diff => { - const arr = res.get(diff.characteristic) || []; - const diffName = map.rawInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty; - arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars }); - res.set(diff.characteristic, arr); - }); - return res; - } - - map.rawInfo._difficultyBeatmapSets.forEach(set => { - set._difficultyBeatmaps.forEach(diff => { - const arr = res.get(set._beatmapCharacteristicName) || []; - arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null }); - res.set(set._beatmapCharacteristicName, arr); - }); - }); - - return res; - }; - const renderMapItem = (map: BsmLocalMap) => { return selected.hash === map.hash)} - diffs={extractMapDiffs(map)} + diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })} mapId={map.songDetails?.id} ranked={map.songDetails?.ranked} autorId={map.songDetails?.uploader.id} @@ -75,3 +53,35 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel ); }, equal); + +export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map { + const res = new Map(); + if (bsvMap && bsvMap.versions?.at(0)?.diffs) { + bsvMap.versions.at(0).diffs.forEach(diff => { + const arr = res.get(diff.characteristic) || []; + arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars }); + res.set(diff.characteristic, arr); + }); + return res; + } + + if (songDetails?.difficulties) { + songDetails?.difficulties.forEach(diff => { + const arr = res.get(diff.characteristic) || []; + const diffName = rawMapInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty; + arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars }); + res.set(diff.characteristic, arr); + }); + return res; + } + + rawMapInfo._difficultyBeatmapSets.forEach(set => { + set._difficultyBeatmaps.forEach(diff => { + const arr = res.get(set._beatmapCharacteristicName) || []; + arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null }); + res.set(set._beatmapCharacteristicName, arr); + }); + }); + + return res; +} diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index efd76a7d3..74c7d1c3b 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useState } from "react"; +import { forwardRef, useContext, useState } from "react"; import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; @@ -6,12 +6,15 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; -import { BehaviorSubject, finalize, lastValueFrom, map, tap } from "rxjs"; +import { BehaviorSubject, finalize, lastValueFrom, map, of, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; -import { LocalBPList } from "shared/models/playlists/local-playlist.models"; +import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { PlaylistItem } from "./playlist-item.component"; import { useStateMap } from "renderer/hooks/use-state-map.hook"; +import { ModalService } from "renderer/services/modale.service"; +import { LocalPlaylistDetailsModal } from "renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component"; +import { InstalledMapsContext } from "../maps-playlists-panel.component"; type Props = { version: BSVersion; @@ -23,17 +26,19 @@ type Props = { export const LocalPlaylistsListPanel = forwardRef(({ version, className, isActive, linkedState }, forwardedRef) => { const playlistService = useService(PlaylistsManagerService); + const modals = useService(ModalService); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); + const { maps$ } = useContext(InstalledMapsContext); const [playlistsLoading, setPlaylistsLoading] = useState(false); - const [playlists, setPlaylists] = useState([]); + const [playlists, setPlaylists] = useState([]); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); - const loadPlaylists = (): Promise => { + const loadLocalPlaylistsDetails = (): Promise => { setPlaylistsLoading(true); - const obs = playlistService.getVersionPlaylists(version).pipe( + const obs = playlistService.getVersionPlaylistsDetails(version).pipe( tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}), map(load => load.data), finalize(() => setPlaylistsLoading(false)) @@ -42,52 +47,45 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl return lastValueFrom(obs); } - const getNbMappersOfPlaylist = (playlist: LocalBPList) => { - return new Set(playlist.songs.map(s => s.songDetails?.uploader?.id ?? s.song.hash)).size; - }; - - const getDurationOfPlaylist = (playlist: LocalBPList) => { - return playlist.songs.reduce((acc, s) => acc + (s.songDetails?.duration ?? 0), 0); - } - - const getMinNpsOfPlaylist = (playlist: LocalBPList) => { - let min = Infinity; - playlist.songs.forEach(s => { - s.songDetails?.difficulties.forEach(d => { - if(d.nps < min){ - min = d.nps; - } - }) - }); - return min === Infinity ? null : min; - } - - const getMaxNpsOfPlaylist = (playlist: LocalBPList) => { - let max = -Infinity; - playlist.songs.forEach(s => { - s.songDetails?.difficulties.forEach(d => { - if(d.nps > max){ - max = d.nps; - } - }) - }); - return max === -Infinity ? null : max; - } - useOnUpdate(() => { if(!isActiveOnce){ return noop(); } - loadPlaylists().then(loadedPlaylists => { + loadLocalPlaylistsDetails().then(loadedPlaylists => { setPlaylists(() => loadedPlaylists); }).catch(() => { - setPlaylists([]) + setPlaylists([]); }).finally(() => { loadPercent$.next(0); }); }, [isActiveOnce, version, linked]); + console.log(maps$.value); + + const openPlaylistDetails = (playlistKey: string) => { + const playlist = playlists.find(p => p.path === playlistKey); + + console.log(playlist.songs); + + modals.openModal(LocalPlaylistDetailsModal, { + data: { + version, + title: playlist.playlistTitle, + image: playlist.image, + author: playlist.playlistAuthor, + description: playlist.playlistDescription, + nbMaps: playlist.nbMaps, + duration: playlist.duration, + maxNps: playlist.maxNps, + minNps: playlist.minNps, + nbMappers: playlist.nbMappers, + installedMaps$: maps$.pipe(map(maps => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase())))), + }, + noStyle: true, + }) + }; + const render = () => { if(playlistsLoading){ return ( @@ -97,25 +95,22 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl if (playlists.length){ return ( - <> -
      - {playlists.map(p => - - )} -
    -
    - +
      + {playlists.map(p => + openPlaylistDetails(p.path)} + /> + )} +
    ) } diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index d549f544f..3d04855ad 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -8,11 +8,9 @@ import dateFormat from 'dateformat'; import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component'; import { GlowEffect } from 'renderer/components/shared/glow-effect.component'; import { useState } from 'react'; -import { FolderOpenIcon } from 'renderer/components/svgs/icons/folder-open-icon.component'; +import { SearchIcon } from 'renderer/components/svgs/icons/search-icon.component'; type Props = { - id: string; - className?: string; title?: string; author?: string; coverBase64?: string; @@ -23,9 +21,10 @@ type Props = { minNps?: number; maxNps?: number; selected?: boolean; + onClickOpen?: () => void; } -export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected }: Props) { +export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected, onClickOpen }: Props) { const color = useThemeColor("first-color"); @@ -33,8 +32,9 @@ export function PlaylistItem({ id, className, title, author, coverUrl, coverBase const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null; const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null; - const minNpsText = minNps ? Math.round(minNps * 10) / 10 : null; - const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : null; + const minNpsText = minNps ? Math.round(minNps * 10) / 10 : 0; + const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : 0; + const showNps = minNps !== undefined && maxNps !== undefined; const durationText = (() => { if (!duration) { @@ -45,27 +45,33 @@ export function PlaylistItem({ id, className, title, author, coverUrl, coverBase return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss"); })(); + // TODO : Translate + return ( - setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > -
    +
    - +
    - +
    -

    {title}

    +

    {title}

    Créé par {author}

    { nbMapsText &&
    {nbMapsText}
    } { nbMappersText &&
    {nbMappersText}
    } { durationText &&
    {durationText}
    } - { (minNps && maxNps) &&
    {`${minNpsText} - ${maxNpsText}`}
    } + { showNps &&
    {`${minNpsText} - ${maxNpsText}`}
    }
    diff --git a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx index 5d4169d23..4fbef88f6 100644 --- a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx @@ -13,8 +13,8 @@ import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bs export const LoginToSteamModal: ModalComponent< { username: string; password: string; stay: boolean, method: "form"|"qr" }, { qrCode$: Observable, logged$: Observable } -> = ({ resolver, data }) => { - +> = ({ resolver, options: {data} }) => { + const modal = useService(ModalService); const [username, setUsername] = useState(""); @@ -82,10 +82,10 @@ export const LoginToSteamModal: ModalComponent<
    - {t("modals.steam-login.inputs.qr.label")} + {t("modals.steam-login.inputs.qr.label")}
    - {(qrCodeUrl ? - : + {(qrCodeUrl ? + : )}
    diff --git a/src/renderer/components/modal/modal-types/bs-downgrade/steam-mobile-approve-modal.component.tsx b/src/renderer/components/modal/modal-types/bs-downgrade/steam-mobile-approve-modal.component.tsx index 8cbaf9673..0c60a583d 100644 --- a/src/renderer/components/modal/modal-types/bs-downgrade/steam-mobile-approve-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/bs-downgrade/steam-mobile-approve-modal.component.tsx @@ -6,8 +6,8 @@ import { useTranslation } from "renderer/hooks/use-translation.hook"; import { Observable } from "rxjs"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; -export const SteamMobileApproveModal: ModalComponent }> = ({ resolver, data }) => { - +export const SteamMobileApproveModal: ModalComponent }> = ({ resolver, options: {data} }) => { + const t = useTranslation(); useOnUpdate(() => { @@ -36,4 +36,4 @@ export const SteamMobileApproveModal: ModalComponent{t("modals.steam-auth-approve.not-access-to-steam-app")} ); -}; \ No newline at end of file +}; diff --git a/src/renderer/components/modal/modal-types/chabgelog-modal/changelog-modal.component.tsx b/src/renderer/components/modal/modal-types/chabgelog-modal/changelog-modal.component.tsx index 4c461e223..2e5a54cd9 100644 --- a/src/renderer/components/modal/modal-types/chabgelog-modal/changelog-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/chabgelog-modal/changelog-modal.component.tsx @@ -7,7 +7,7 @@ import DOMPurify from 'dompurify'; import './changelog-modal.component.css'; import Tippy from "@tippyjs/react"; -export const ChangelogModal: ModalComponent = ({ resolver, data: changelog }) => { +export const ChangelogModal: ModalComponent = ({ resolver, options: {data: changelog} }) => { const linkOpener = useService(LinkOpenerService); const openGithub = () => linkOpener.open("https://github.com/Zagrios/bs-manager"); diff --git a/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx b/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx index 18f5fc4cc..e38b35cfa 100644 --- a/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx @@ -12,7 +12,7 @@ import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component"; import Tippy from "@tippyjs/react"; -export const CreateLaunchShortcutModal: ModalComponent = ({resolver, data}) => { +export const CreateLaunchShortcutModal: ModalComponent = ({resolver, options: {data}}) => { const bsLauncher = useService(BSLauncherService); @@ -76,10 +76,10 @@ export const CreateLaunchShortcutModal: ModalComponent
    - setAdditionalArgsString(e.target.value)} /> diff --git a/src/renderer/components/modal/modal-types/delete-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/delete-maps-modal.component.tsx index 819d19158..a31dbeefa 100644 --- a/src/renderer/components/modal/modal-types/delete-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/delete-maps-modal.component.tsx @@ -10,8 +10,8 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; import { useService } from "renderer/hooks/use-service.hook"; -export const DeleteMapsModal: ModalComponent = ({ resolver, data: { linked, maps } }) => { - +export const DeleteMapsModal: ModalComponent = ({ resolver, options: {data : { linked, maps }}}) => { + const config = useService(ConfigurationService); const t = useTranslation(); diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 389a889ef..fce1d1ebd 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -20,8 +20,9 @@ import { useTranslation } from "renderer/hooks/use-translation.hook"; import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { useService } from "renderer/hooks/use-service.hook"; +import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component"; -export const DownloadMapsModal: ModalComponent = ({ data: { ownedMaps, version } }) => { +export const DownloadMapsModal: ModalComponent = ({ options: {data : { ownedMaps, version }} }) => { const beatSaver = useService(BeatSaverService); const mapsDownloader = useService(MapsDownloaderService); const progressBar = useService(ProgressBarService); @@ -84,24 +85,32 @@ export const DownloadMapsModal: ModalComponent setLoading(() => false)); }; - const extractMapDiffs = (map: BsvMapDetail): Map => { - const res = new Map(); - if (map.versions.at(0).diffs) { - map.versions.at(0).diffs.forEach(diff => { - const arr = res.get(diff.characteristic) || []; - arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars }); - res.set(diff.characteristic, arr); - }); - } - return res; - }; - const renderMap = (map: BsvMapDetail) => { const isMapOwned = map.versions.some(version => ownedMapHashs.includes(version.hash)); const isDownloading = map.id === currentDownload?.map?.id; const inQueue = mapsInQueue.some(toDownload => equal(toDownload.version, version) && toDownload.map.id === map.id); - return ; + return ; }; const handleDownloadMap = useCallback((map: BsvMapDetail) => { diff --git a/src/renderer/components/modal/modal-types/edit-version-modal.component.tsx b/src/renderer/components/modal/modal-types/edit-version-modal.component.tsx index fc1e549b2..c770efa97 100644 --- a/src/renderer/components/modal/modal-types/edit-version-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/edit-version-modal.component.tsx @@ -9,7 +9,7 @@ import { ConfigurationService } from "renderer/services/configuration.service"; import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; import { BSVersion } from "shared/bs-version.interface"; -export const EditVersionModal: ModalComponent<{ name: string; color: string }, { version: BSVersion; clone?: boolean }> = ({ resolver, data }) => { +export const EditVersionModal: ModalComponent<{ name: string; color: string }, { version: BSVersion; clone?: boolean }> = ({ resolver, options: {data} }) => { const { version, clone } = data; const configService = useService(ConfigurationService); diff --git a/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx index e219c93db..7adf355c0 100644 --- a/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx @@ -12,7 +12,7 @@ import { ConfigurationService } from "renderer/services/configuration.service"; import { ModelsManagerService } from "renderer/services/models-management/models-manager.service"; import { useService } from "renderer/hooks/use-service.hook"; -export const DeleteModelsModal: ModalComponent = ({ resolver, data }) => { +export const DeleteModelsModal: ModalComponent = ({ resolver, options: {data} }) => { const config = useService(ConfigurationService); const t = useTranslation(); const [remember, setRemember] = useState(config.get(ModelsManagerService.REMEMBER_CHOICE_DELETE_MODEL_KEY) || false); diff --git a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx index d6982bd36..41a0033b3 100644 --- a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx @@ -23,7 +23,7 @@ import { catchError, of } from "rxjs"; import Tippy from "@tippyjs/react"; import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface"; -export const DownloadModelsModal: ModalComponent = ({ data: { version, type, owned } }) => { +export const DownloadModelsModal: ModalComponent = ({ options: {data: { version, type, owned }} }) => { const modelsDownloader = useService(ModelsDownloaderService); const modelSaber = useService(ModelSaberService); const os = useService(OsDiagnosticService); diff --git a/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx index ecaafbc73..3350200b2 100644 --- a/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/link-models-modal.component.tsx @@ -7,7 +7,7 @@ import { ModalComponent, ModalExitCode } from "renderer/services/modale.service" import { MSModelType } from "shared/models/models/model-saber.model"; import BeatRunning from "../../../../../../assets/images/apngs/beat-running.png"; -export const LinkModelsModal: ModalComponent = ({ resolver, data }) => { +export const LinkModelsModal: ModalComponent = ({ resolver, options: {data} }) => { const t = useTranslation(); const [keepMaps, setKeepMaps] = useState(true); diff --git a/src/renderer/components/modal/modal-types/models/unlink-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/unlink-models-modal.component.tsx index bd801989d..5af7a65bb 100644 --- a/src/renderer/components/modal/modal-types/models/unlink-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/unlink-models-modal.component.tsx @@ -7,7 +7,7 @@ import { ModalComponent, ModalExitCode } from "renderer/services/modale.service" import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; import { MSModelType } from "shared/models/models/model-saber.model"; -export const UnlinkModelsModal: ModalComponent = ({ resolver, data }) => { +export const UnlinkModelsModal: ModalComponent = ({ resolver, options: {data} }) => { const t = useTranslation(); const [keepMaps, setKeepMaps] = useState(true); diff --git a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx new file mode 100644 index 000000000..39c9a2e5f --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx @@ -0,0 +1,57 @@ +import { ModalComponent } from "renderer/services/modale.service" +import { PlaylistDetailsTemplate, PlaylistDetailsTemplateProps } from "./playlist-details-template.component" +import { Observable } from "rxjs" +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface" +import { BSVersion } from "shared/bs-version.interface"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component"; +import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component"; + +interface Props extends Omit { + version: BSVersion + installedMaps$: Observable; +} + +export const LocalPlaylistDetailsModal: ModalComponent = ({resolver, options}) => { + + const installedMaps = useObservable(() => options.data.installedMaps$, undefined); + + console.log(installedMaps); + + const renderMaps = () => { + if (!installedMaps) { + return null; + } + + return ( +
      + {installedMaps.map(map => ( + + ))} +
    + ) + } + + return ( + + {renderMaps()} + + ) +} diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx new file mode 100644 index 000000000..7046abe5e --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from "react" +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import dateFormat from "dateformat"; +import { MapIcon } from "renderer/components/svgs/icons/map-icon.component"; +import { PersonIcon } from "renderer/components/svgs/icons/person-icon.component"; +import { ClockIcon } from "renderer/components/svgs/icons/clock-icon.component"; +import { NpsIcon } from "renderer/components/svgs/icons/nps-icon.component"; +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { ThemeColorGradientSpliter } from "renderer/components/shared/theme-color-gradient-spliter.component"; + +export type PlaylistDetailsTemplateProps = { + title: string; + image: string; + author: string; + description: string; + nbMaps: number; + duration: number; + nbMappers: number; + minNps: number; + maxNps: number; + children?: ReactNode; +} + +export function PlaylistDetailsTemplate({title, image, author, description, nbMaps, duration, nbMappers, minNps, maxNps, children}: PlaylistDetailsTemplateProps) { + + const color = useThemeColor("first-color"); + + const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null; + const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null; + const minNpsText = minNps ? Math.round(minNps * 10) / 10 : 0; + const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : 0; + const showNps = minNps !== undefined && maxNps !== undefined; + + const durationText = (() => { + if (!duration) { + return null; + } + const date = new Date(0); + date.setSeconds(duration); + return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss"); + })(); + + return ( +
    +
    + + +
    +

    {title}

    +

    {description}

    +

    Créé par {author}

    + +
    + { nbMapsText &&
    {nbMapsText}
    } + { nbMappersText &&
    {nbMappersText}
    } + { durationText &&
    {durationText}
    } + { showNps &&
    {`${minNpsText} - ${maxNpsText}`}
    } +
    + +
    +
    + + {children} +
    + ) +} diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx index 09e319a53..7372bb8d3 100644 --- a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -14,7 +14,7 @@ import { ModalComponent } from "renderer/services/modale.service"; import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionType } from "renderer/services/version-folder-linker.service"; import { BSVersion } from "shared/bs-version.interface"; -export const ShareFoldersModal: ModalComponent = ({ data }) => { +export const ShareFoldersModal: ModalComponent = ({ options: {data} }) => { const SHARED_FOLDERS_KEY = "default-shared-folders"; const config = useService(ConfigurationService); diff --git a/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx index d236e4495..3f0a9cedd 100644 --- a/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx @@ -5,7 +5,7 @@ import { BsmImage } from "renderer/components/shared/bsm-image.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { BSVersion } from "shared/bs-version.interface"; -export const UninstallAllModsModal: ModalComponent = ({ resolver, data }) => { +export const UninstallAllModsModal: ModalComponent = ({ resolver, options: {data} }) => { const version = data; const t = useTranslation(); diff --git a/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx index 32e19b4f0..4108c0e42 100644 --- a/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx @@ -5,7 +5,7 @@ import { BsmImage } from "renderer/components/shared/bsm-image.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { Mod } from "shared/models/mods/mod.interface"; -export const UninstallModModal: ModalComponent = ({ resolver, data }) => { +export const UninstallModModal: ModalComponent = ({ resolver, options: {data} }) => { const mod = data; const t = useTranslation(); diff --git a/src/renderer/components/modal/modal-types/uninstall-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-modal.component.tsx index c8e84cbd3..bc0d543b8 100644 --- a/src/renderer/components/modal/modal-types/uninstall-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/uninstall-modal.component.tsx @@ -5,7 +5,7 @@ import { BsmButton } from "renderer/components/shared/bsm-button.component"; import { BsmImage } from "renderer/components/shared/bsm-image.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; -export const UninstallModal: ModalComponent = ({ resolver, data }) => { +export const UninstallModal: ModalComponent = ({ resolver, options: {data} }) => { const version = data; const t = useTranslation(); diff --git a/src/renderer/components/modal/modal.component.tsx b/src/renderer/components/modal/modal.component.tsx index 53bde46cd..8c232824e 100644 --- a/src/renderer/components/modal/modal.component.tsx +++ b/src/renderer/components/modal/modal.component.tsx @@ -1,17 +1,16 @@ -import { ModalExitCode, ModalService } from "renderer/services/modale.service"; +import { ModalExitCode, ModalObject, ModalService } from "renderer/services/modale.service"; import { AnimatePresence, motion } from "framer-motion"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { useEffect } from "react"; import { BsmIcon } from "../svgs/bsm-icon.component"; +import { ThemeColorGradientSpliter } from "../shared/theme-color-gradient-spliter.component"; export function Modal() { const modalSevice = ModalService.getInstance(); - const ModalComponent = useObservable(() => modalSevice.getModalToShow()); - - const modalData = modalSevice.getModalData(); - const resolver = modalSevice.getResolver(); + const modals = useObservable(() => modalSevice.getModalToShow()); + const currentModal = modals?.at(-1); const { firstColor, secondColor } = useThemeColor(); @@ -20,10 +19,10 @@ export function Modal() { if (e.key !== "Escape") { return; } - resolver?.(ModalExitCode.NO_CHOICE); + currentModal.resolver({ exitCode: ModalExitCode.CLOSED }); }; - if (ModalComponent) { + if (currentModal) { window.addEventListener("keyup", onEscape); } else { window.removeEventListener("keyup", onEscape); @@ -32,32 +31,40 @@ export function Modal() { return () => { window.removeEventListener("keyup", onEscape); }; - }, [ModalComponent]); + }, [currentModal]); + + const renderModal = (modal: ModalObject) => { + if (!modal?.modal) { return null; } + + if(modal.options?.noStyle){ + return + } + + return ( +
    + +
    { + e.stopPropagation(); + modal.resolver({ exitCode: ModalExitCode.CLOSED }); + }} + > + +
    + +
    + ) + } return ( - - {ModalComponent && resolver && ( -
    - modalSevice.resolve({ exitCode: ModalExitCode.NO_CHOICE })} className="absolute top-0 bottom-0 right-0 left-0 bg-black" initial={{ opacity: 0 }} animate={{ opacity: ModalComponent && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} /> - -
    -
    - -
    -
    { - e.stopPropagation(); - resolver(ModalExitCode.CLOSED); - }} - > - -
    - -
    + + {currentModal && currentModal.resolver({ exitCode: ModalExitCode.NO_CHOICE })} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} />} + {modals?.map((modal, index) => ( + + {renderModal(modal)} -
    - )} -
    + ))} + ); } diff --git a/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx index 6cc1257e3..2ee8f606e 100644 --- a/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx +++ b/src/renderer/components/shared/bs-content-tab-panel/bs-content-tab-panel.component.tsx @@ -18,11 +18,11 @@ export function BsContentTabPanel({className, tabIndex, tabs, onTabChange, child
    diff --git a/src/renderer/components/shared/theme-color-gradient-spliter.component.tsx b/src/renderer/components/shared/theme-color-gradient-spliter.component.tsx new file mode 100644 index 000000000..a9bea849f --- /dev/null +++ b/src/renderer/components/shared/theme-color-gradient-spliter.component.tsx @@ -0,0 +1,11 @@ +import { ComponentProps } from "react"; +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; + +export function ThemeColorGradientSpliter(props: ComponentProps<"div">) { + + const colors = useThemeColor(); + + return ( +
    + ) +} diff --git a/src/renderer/components/svgs/icons/search-icon.component.tsx b/src/renderer/components/svgs/icons/search-icon.component.tsx index 822931565..88c8076e2 100644 --- a/src/renderer/components/svgs/icons/search-icon.component.tsx +++ b/src/renderer/components/svgs/icons/search-icon.component.tsx @@ -1,9 +1,9 @@ -import { CSSProperties } from "react"; +import { createSvgIcon } from "../svg-icon.type"; -export function SearchIcon(props: { className?: string; style?: CSSProperties }) { +export const SearchIcon = createSvgIcon((props, ref) => { return ( - + ); -} +}); diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index 3e36b3b7c..dc1282c00 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -46,7 +46,7 @@ export function VersionViewer() { const verifyFiles = () => bsDownloader.verifyBsVersion(state); const uninstall = async () => { - const modalCompleted = await modalService.openModal(UninstallModal, state); + const modalCompleted = await modalService.openModal(UninstallModal, { data: state }); if (modalCompleted.exitCode === ModalExitCode.COMPLETED) { bsUninstallerService.uninstall(state).then(() => { bsVersionManagerService.askInstalledVersions().then(versions => { @@ -79,11 +79,11 @@ export function VersionViewer() { }; const openShareFolderModal = () => { - modalService.openModal(ShareFoldersModal, state); + modalService.openModal(ShareFoldersModal, {data: state}); }; const createLaunchShortcut = async () => { - const { exitCode, data } = await modalService.openModal(CreateLaunchShortcutModal, state); + const { exitCode, data } = await modalService.openModal(CreateLaunchShortcutModal, {data: state}); if(exitCode !== ModalExitCode.COMPLETED){ return; } lastValueFrom(bsLauncher.createLaunchShortcut(data)).then(() => { diff --git a/src/renderer/services/auto-updater.service.ts b/src/renderer/services/auto-updater.service.ts index d4de5b2e7..1e7fdff86 100644 --- a/src/renderer/services/auto-updater.service.ts +++ b/src/renderer/services/auto-updater.service.ts @@ -109,6 +109,6 @@ export class AutoUpdaterService { public async showChangelog(version:string): Promise{ const changelog = await this.getChangelogVersion(version); - this.modal.openModal(ChangelogModal, changelog); + this.modal.openModal(ChangelogModal, {data: changelog}); } } diff --git a/src/renderer/services/bs-mods-manager.service.ts b/src/renderer/services/bs-mods-manager.service.ts index 2aa9819ae..b48eb7471 100644 --- a/src/renderer/services/bs-mods-manager.service.ts +++ b/src/renderer/services/bs-mods-manager.service.ts @@ -90,7 +90,7 @@ export class BsModsManagerService { return; } - const modalRes = await this.modals.openModal(UninstallModModal, mod); + const modalRes = await this.modals.openModal(UninstallModModal, {data: mod}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return; @@ -117,7 +117,7 @@ export class BsModsManagerService { return; } - const modalRes = await this.modals.openModal(UninstallAllModsModal, version); + const modalRes = await this.modals.openModal(UninstallAllModsModal, {data: version}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return; diff --git a/src/renderer/services/bs-version-download/steam-downloader.service.ts b/src/renderer/services/bs-version-download/steam-downloader.service.ts index e679d0ca9..d906169fb 100644 --- a/src/renderer/services/bs-version-download/steam-downloader.service.ts +++ b/src/renderer/services/bs-version-download/steam-downloader.service.ts @@ -15,7 +15,7 @@ import { DownloaderServiceInterface } from "./bs-store-downloader.interface"; import { AbstractBsDownloaderService } from "./abstract-bs-downloader.service"; export class SteamDownloaderService extends AbstractBsDownloaderService implements DownloaderServiceInterface{ - + private static instance: SteamDownloaderService; public static getInstance(): SteamDownloaderService { @@ -102,7 +102,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen take(1), ).subscribe(async () => { const logged$ = events$.pipe(filter(event => event.subType === DepotDownloaderInfoEvent.SteamID), take(1)); - const res = await this.modalService.openModal(SteamMobileApproveModal, { logged$ }); + const res = await this.modalService.openModal(SteamMobileApproveModal, {data: { logged$ }}); if(res.exitCode !== ModalExitCode.COMPLETED){ return this.stopDownload(); } @@ -128,7 +128,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.verification-finished"}); } return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.download-success"}); - })); + })); return subs; } @@ -182,7 +182,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen }), share({connector: () => new ReplaySubject(1)}) ); - + } private tryAutoDownloadBsVersion(downloadInfo: DownloadSteamInfo){ @@ -217,7 +217,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen } this.progressBarService.show(this.downloadProgress$, true); - + const downloadPromise = (async () => { const haveDotNet = await this.isDotNet6Installed().catch(() => false); @@ -227,16 +227,16 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen } const downloadInfo: DownloadSteamInfo = {bsVersion, isVerification} - + const autoDownload = await lastValueFrom(this.tryAutoDownloadBsVersion(downloadInfo)).then(() => true).catch(() => false); - + if(autoDownload){ return Promise.resolve(); } const qrCodeDownload$ = this.startQrCodeDownload(downloadInfo); const qrCode$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.QRCode), map(event => event.data as string)); const logged$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.SteamID), map(event => event.data as string), take(1)); - const loginRes = await this.modalService.openModal(LoginToSteamModal, { qrCode$, logged$ }); + const loginRes = await this.modalService.openModal(LoginToSteamModal, {data: { qrCode$, logged$ }}); if(loginRes.exitCode !== ModalExitCode.COMPLETED){ return Promise.resolve(); diff --git a/src/renderer/services/bs-version-manager.service.ts b/src/renderer/services/bs-version-manager.service.ts index 313d26ad6..5faf00eed 100644 --- a/src/renderer/services/bs-version-manager.service.ts +++ b/src/renderer/services/bs-version-manager.service.ts @@ -66,7 +66,7 @@ export class BSVersionManagerService { } public async editVersion(version: BSVersion): Promise { - const modalRes = await this.modalService.openModal(EditVersionModal, { version, clone: false }); + const modalRes = await this.modalService.openModal(EditVersionModal, {data: { version, clone: false }}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return null; } @@ -90,7 +90,7 @@ export class BSVersionManagerService { if (!this.progressBar.require()) { return null; } - const modalRes = await this.modalService.openModal(EditVersionModal, { version, clone: true }); + const modalRes = await this.modalService.openModal(EditVersionModal, {data: { version, clone: true }}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return null; } @@ -128,7 +128,7 @@ export class BSVersionManagerService { if(resModal.exitCode !== ModalExitCode.COMPLETED){ return; } - + const store = resModal.data; const folderRes = await lastValueFrom(this.ipcService.sendV2<{ canceled: boolean; filePaths: string[] }>("choose-folder")); diff --git a/src/renderer/services/maps-downloader.service.ts b/src/renderer/services/maps-downloader.service.ts index 7f321ecd4..42173beb8 100644 --- a/src/renderer/services/maps-downloader.service.ts +++ b/src/renderer/services/maps-downloader.service.ts @@ -78,7 +78,7 @@ export class MapsDownloaderService { } public async openDownloadMapModal(version?: BSVersion, ownedMaps: BsmLocalMap[] = []): Promise> { - const res = await this.modals.openModal(DownloadMapsModal, { version, ownedMaps }); + const res = await this.modals.openModal(DownloadMapsModal, {data: { version, ownedMaps }}); this.progressBar.setStyle(null); return res; } diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 8aeed33f3..a63ca2c3a 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -89,7 +89,7 @@ export class MapsManagerService { const askModal = maps.length > 1 || !this.config.get(MapsManagerService.REMEMBER_CHOICE_DELETE_MAP_KEY); if (askModal) { - const modalRes = await this.modal.openModal(DeleteMapsModal, { linked: versionLinked, maps }); + const modalRes = await this.modal.openModal(DeleteMapsModal, {data: { linked: versionLinked, maps }}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return false; } diff --git a/src/renderer/services/modale.service.ts b/src/renderer/services/modale.service.ts index 650c89aa4..e041def20 100644 --- a/src/renderer/services/modale.service.ts +++ b/src/renderer/services/modale.service.ts @@ -1,12 +1,10 @@ import { Observable, BehaviorSubject } from "rxjs"; -import { timeout } from "rxjs/operators"; export class ModalService { private static instance: ModalService; - private _modalToShow$: BehaviorSubject = new BehaviorSubject(null); + private _modalToShow$: BehaviorSubject = new BehaviorSubject([]); private modalData: unknown = null; - private resolver: (value: ModalResponse | PromiseLike) => void = null; private constructor() {} @@ -17,47 +15,33 @@ export class ModalService { return this.instance; } - private close() { - if (this.resolver) { - this.resolve({ exitCode: ModalExitCode.NO_CHOICE }); - } - this._modalToShow$.next(null); - } - public getModalData(): Type { return this.modalData as Type; } - public getResolver(): any { - return this.resolver; - } - - public resolve(data: ModalResponse): void { - this.resolver(data); - } - - public async openModal(modal: ModalComponent, data?: K): Promise> { - this.close(); - await timeout(100); // Must wait resolve + public async openModal(modal: ModalComponent, options?: ModalOptions): Promise> { + let resolver: (value: ModalResponse | PromiseLike) => void; const promise = new Promise>(resolve => { - this.resolver = resolve as (value: ModalResponse | PromiseLike) => void; + resolver = resolve as (value: ModalResponse | PromiseLike) => void; }); - this._modalToShow$.next(modal as ModalComponent); - promise.then(() => this.close()); - if (data) { - this.modalData = data; - } else { - this.modalData = null; - } + const modalObj = {modal: modal as ModalComponent, resolver: resolver, options}; + this._modalToShow$.next([...this._modalToShow$.getValue(), modalObj]); + + promise.then(() => { + this._modalToShow$.next(this._modalToShow$.getValue().filter(m => m !== modalObj)); + }); + return promise; } - public getModalToShow(): Observable { + public getModalToShow(): Observable { return this._modalToShow$.asObservable(); } } -export type ModalComponent = ({ resolver, data }: { resolver: (x: ModalResponse) => void; data?: Receive }) => JSX.Element; +export type ModalOptions = { data?: T, noStyle?: boolean } +export type ModalComponent = ({ resolver, options }: { resolver: (x: ModalResponse) => void; options?: ModalOptions }) => JSX.Element; +export type ModalObject = {modal: ModalComponent, resolver: (value: ModalResponse | PromiseLike) => void, options: ModalOptions}; export const enum ModalExitCode { NO_CHOICE = -1, diff --git a/src/renderer/services/models-management/models-downloader.service.ts b/src/renderer/services/models-management/models-downloader.service.ts index a1b395a2a..48ecf1e65 100644 --- a/src/renderer/services/models-management/models-downloader.service.ts +++ b/src/renderer/services/models-management/models-downloader.service.ts @@ -118,7 +118,7 @@ export class ModelsDownloaderService { } public openDownloadModelsModal(version: BSVersion, type?: MSModelType, owned?: BsmLocalModel[]): Promise> { - return this.modal.openModal(DownloadModelsModal, { version, type, owned }); + return this.modal.openModal(DownloadModelsModal, {data: { version, type, owned }}); } public async oneClickInstallModel(model: MSModel): Promise { diff --git a/src/renderer/services/models-management/models-manager.service.ts b/src/renderer/services/models-management/models-manager.service.ts index 0c1c230ef..39837c111 100644 --- a/src/renderer/services/models-management/models-manager.service.ts +++ b/src/renderer/services/models-management/models-manager.service.ts @@ -73,7 +73,7 @@ export class ModelsManagerService { } public async linkModels(type: MSModelType, version?: BSVersion): Promise { - const res = await this.modalService.openModal(LinkModelsModal, type); + const res = await this.modalService.openModal(LinkModelsModal, {data: type}); if (res.exitCode !== ModalExitCode.COMPLETED) { return null; @@ -82,13 +82,12 @@ export class ModelsManagerService { return this.versionFolderLinked.linkVersionFolder({ version, relativeFolder: MODEL_TYPE_FOLDERS[type], - type: VersionLinkerActionType.Link, options: { keepContents: res.data !== false }, }); } public async unlinkModels(type: MSModelType, version?: BSVersion): Promise { - const res = await this.modalService.openModal(UnlinkModelsModal, type); + const res = await this.modalService.openModal(UnlinkModelsModal, {data: type}); if (res.exitCode !== ModalExitCode.COMPLETED) { return null; @@ -97,7 +96,6 @@ export class ModelsManagerService { return this.versionFolderLinked.unlinkVersionFolder({ version, relativeFolder: MODEL_TYPE_FOLDERS[type], - type: VersionLinkerActionType.Unlink, options: { keepContents: res.data !== false }, }); } @@ -165,7 +163,7 @@ export class ModelsManagerService { return false; })(); - const res = await this.modalService.openModal(DeleteModelsModal, { models, linked }); + const res = await this.modalService.openModal(DeleteModelsModal, {data: { models, linked }}); if (res.exitCode !== ModalExitCode.COMPLETED) { return Promise.resolve([]); } diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index 9aca80cc5..461b01f4b 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -3,9 +3,8 @@ import { IpcService } from "./ipc.service"; import { Observable, lastValueFrom } from "rxjs"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; import { Progression } from "main/helpers/fs.helpers"; -import { LocalBPList } from "shared/models/playlists/local-playlist.models"; +import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { ModalExitCode, ModalService } from "./modale.service"; -import { LinkMapsModal } from "renderer/components/modal/modal-types/link-maps-modal.component"; import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component"; import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component"; @@ -31,8 +30,8 @@ export class PlaylistsManagerService { this.modal = ModalService.getInstance(); } - public getVersionPlaylists(version: BSVersion): Observable> { - return this.ipc.sendV2("get-version-playlists", { args: version }); + public getVersionPlaylistsDetails(version: BSVersion): Observable> { + return this.ipc.sendV2("get-version-playlists-details", { args: version }); } public async linkVersion(version: BSVersion): Promise { diff --git a/src/renderer/windows/App.tsx b/src/renderer/windows/App.tsx index 6ea158235..920d76880 100644 --- a/src/renderer/windows/App.tsx +++ b/src/renderer/windows/App.tsx @@ -24,6 +24,8 @@ import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { useService } from "renderer/hooks/use-service.hook"; import { AutoUpdaterService } from "renderer/services/auto-updater.service"; import { gt } from "semver" +import { ModalService } from "renderer/services/modale.service"; +import { ChooseStore } from "renderer/components/modal/modal-types/bs-downgrade/choose-store-modal.component"; export default function App() { useService(OsDiagnosticService); @@ -34,6 +36,7 @@ export default function App() { const notification = useService(NotificationService); const config = useService(ConfigurationService); const autoUpdater = useService(AutoUpdaterService); + const modals = useService(ModalService); const location = useLocation(); const navigate = useNavigate(); @@ -55,6 +58,7 @@ export default function App() { }; const checkOneClicks = async () => { + if (config.get("not-remind-oneclick") === true) { return; } diff --git a/src/shared/models/maps/beat-saver.model.ts b/src/shared/models/maps/beat-saver.model.ts index 0cb2968b1..5980a12dc 100644 --- a/src/shared/models/maps/beat-saver.model.ts +++ b/src/shared/models/maps/beat-saver.model.ts @@ -1,3 +1,5 @@ +import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache.model"; + export interface BsvMapDetail { automapper: boolean; createdAt: string; @@ -115,10 +117,10 @@ export interface BsvMapTestplay { export interface BsvMapDifficulty { bombs: number; - characteristic: BsvMapCharacteristic; + characteristic: SongDetailDiffCharactertistic; chroma: boolean; cinema: boolean; - difficulty: BsvMapDifficultyType; + difficulty: SongDiffName; events: number; length: number; maxScore: number; diff --git a/src/shared/models/playlists/local-playlist.models.ts b/src/shared/models/playlists/local-playlist.models.ts index f69d22eaf..a04376c56 100644 --- a/src/shared/models/playlists/local-playlist.models.ts +++ b/src/shared/models/playlists/local-playlist.models.ts @@ -1,13 +1,16 @@ -import { SongDetails } from "../maps"; -import { BPList, PlaylistSong } from "./playlist.interface"; +import { BPList } from "./playlist.interface"; -export interface LocalBPList extends BPList { +export interface LocalBPList extends BPList { path: string; } -export interface LocalBpListSong { - song: PlaylistSong; - songDetails?: SongDetails; - songUrl?: string; - coverUrl?: string; +export interface LocalBPListsDetails extends LocalBPList { + nbMaps: number; + id?: number; + nbMappers?: number; + duration?: number; + minNps?: number; + maxNps?: number; } + + diff --git a/src/shared/models/playlists/playlist.interface.ts b/src/shared/models/playlists/playlist.interface.ts index 5fde662d7..4667f7ad9 100644 --- a/src/shared/models/playlists/playlist.interface.ts +++ b/src/shared/models/playlists/playlist.interface.ts @@ -6,7 +6,7 @@ export interface BPList { playlistAuthor: string; playlistDescription?: string; image: string; - customData: unknown; + customData?: CustomDataBPList; songs: SongType[]; } @@ -24,3 +24,7 @@ export interface DownloadPlaylistProgressionData { playlistInfos: BPList; playlistPath: string; } + +export interface CustomDataBPList { + syncURL?: string; +} From 501def3a9479c1bd4df37420cc3ce160a9b02a27 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:21:05 +0100 Subject: [PATCH 10/36] [feature-107] Audio player can now take list of songs to play --- assets/jsons/bs-versions.json | 4 +- package.json | 2 +- .../maps/map-item.component.tsx | 2 +- ...local-playlist-details-modal.component.tsx | 59 +++++---- src/renderer/services/audio-player.service.ts | 120 ++++++++++-------- 5 files changed, 102 insertions(+), 85 deletions(-) diff --git a/assets/jsons/bs-versions.json b/assets/jsons/bs-versions.json index 6484682a4..719425bfc 100644 --- a/assets/jsons/bs-versions.json +++ b/assets/jsons/bs-versions.json @@ -700,7 +700,7 @@ "ReleaseDate": "1708091473", "year": "2024" }, - { + { "BSVersion": "1.35.0", "BSManifest": "1490986193481243578", "OculusBinaryId": "6720809361352119", @@ -709,4 +709,4 @@ "ReleaseDate": "1709831174", "year": "2024" } -] +] \ No newline at end of file diff --git a/package.json b/package.json index 02badae0c..b56ab4748 100644 --- a/package.json +++ b/package.json @@ -170,8 +170,8 @@ "@types/color": "^3.0.3", "@types/crypto-js": "^4.2.1", "@types/dateformat": "^5.0.0", - "@types/got": "^9.6.12", "@types/dompurify": "^3.0.5", + "@types/got": "^9.6.12", "@types/jest": "^29.5.11", "@types/node": "20.11.15", "@types/node-fetch": "^2.6.3", diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 48ba662be..213157ebb 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -109,7 +109,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, if (!audioPlayer.playing && audioPlayer.src === songUrl) { return audioPlayer.resume(); } - audioPlayer.play(songUrl, bpm); + audioPlayer.play([{ src: songUrl, bpm }]); }; const bottomBarHoverStart = () => { diff --git a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx index 39c9a2e5f..87eed38dc 100644 --- a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx @@ -6,6 +6,8 @@ import { BSVersion } from "shared/bs-version.interface"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component"; import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component"; +import { useService } from "renderer/hooks/use-service.hook"; +import { AudioPlayerService } from "renderer/services/audio-player.service"; interface Props extends Omit { version: BSVersion @@ -14,39 +16,46 @@ interface Props extends Omit { export const LocalPlaylistDetailsModal: ModalComponent = ({resolver, options}) => { + const audioPlayer = useService(AudioPlayerService); + const installedMaps = useObservable(() => options.data.installedMaps$, undefined); - console.log(installedMaps); + const playPlaylist = () => { + if (!installedMaps) { + return; + } + audioPlayer.play(installedMaps.map(map => ({ src: map.songUrl, bpm: map.rawInfo?._beatsPerMinute ?? 0}))); + }; const renderMaps = () => { if (!installedMaps) { return null; } - return ( -
      - {installedMaps.map(map => ( - - ))} -
    - ) + return ( +
      + {installedMaps.map(map => ( + + ))} +
    + ) } return ( diff --git a/src/renderer/services/audio-player.service.ts b/src/renderer/services/audio-player.service.ts index 2d7fec72f..42ad77084 100644 --- a/src/renderer/services/audio-player.service.ts +++ b/src/renderer/services/audio-player.service.ts @@ -1,5 +1,15 @@ -import { Observable, BehaviorSubject } from "rxjs"; -import { ConfigurationService } from "./configuration.service"; +import { BehaviorSubject, Observable, map } from 'rxjs'; +import { ConfigurationService } from './configuration.service'; + +interface PlayerVolume { + volume: number; + muted: boolean; +} + +interface PlayerSound { + src: string; + bpm?: number; +} export class AudioPlayerService { private static instance: AudioPlayerService; @@ -11,52 +21,50 @@ export class AudioPlayerService { return AudioPlayerService.instance; } - private readonly config: ConfigurationService; - - private readonly player: HTMLAudioElement; + private player = new Audio(); + private config = ConfigurationService.getInstance(); - private readonly _src$: BehaviorSubject = new BehaviorSubject(""); - private readonly _playing$: BehaviorSubject = new BehaviorSubject(false); - private readonly _bpm$: BehaviorSubject = new BehaviorSubject(0); - private readonly _volume$: BehaviorSubject; + private _soundsIndex$ = new BehaviorSubject(0); + private _sounds$ = new BehaviorSubject([]); + private _currentSound$ = this._soundsIndex$.pipe( + map(index => this._sounds$.value[index]), + ); - private lastVolume: number; + private _playing$ = new BehaviorSubject(false); + private _bpm$ = new BehaviorSubject(0); + private _volume$ = new BehaviorSubject( + this.config.get('audio-level') || { volume: 0.5, muted: false }, + ); private constructor() { - this.config = ConfigurationService.getInstance(); - - this._volume$ = new BehaviorSubject(this.config.get("audio-level") || { volume: 0.5, muted: false }); - - this.lastVolume = this._volume$.value.volume; - - this.player = new Audio(); - this.player.onplay = () => this._playing$.next(true); this.player.onpause = () => this._playing$.next(false); - this.player.onended = () => this._playing$.next(false); + this.player.onended = () => { + this._playing$.next(false); + const nextIndex = (this._soundsIndex$.value + 1) % this._sounds$.value.length; + if (nextIndex !== 0 || this._sounds$.value.length > 1) { + this._soundsIndex$.next(nextIndex); + } + }; + + this._currentSound$.subscribe(sound => { + if(!sound){ return; } + this.player.src = sound.src; + this._bpm$.next(sound.bpm || 0); + this.player.play().catch(error => console.error('Error playing sound:', error)); + }); this._volume$.subscribe(volume => { this.player.volume = volume.volume; this.player.muted = volume.muted; - this.config.set("audio-level", volume); + this.config.set('audio-level', volume); }); } - public play(src: string, bpm = 0): Promise { + public play(sounds: PlayerSound[]): void { this.pause(); - this.player.src = src; - this._src$.next(src); - this._bpm$.next(bpm); - return this.player.play(); - } - - public playlist(songs: {src: string, bpm: number}[], index: number): void { - this.play(songs[index].src, songs[index].bpm); - this.player.onended = () => { - if (index < songs.length - 1) { - this.playlist(songs, index + 1); - } - }; + this._sounds$.next(sounds); + this._soundsIndex$.next(0); // Ensures we start from the first sound } public pause(): void { @@ -65,7 +73,13 @@ export class AudioPlayerService { } public resume(): Promise { - return this.player.play(); + if(this._playing$.value){ + return Promise.resolve(); + } + + return this.player.play().then(() => { + this._playing$.next(true); + }).catch(error => console.error('Error resuming sound:', error)); } public setVolume(volume: number): void { @@ -73,14 +87,6 @@ export class AudioPlayerService { this._volume$.next(playerVolume); } - public setFinalVolume(volume: number): void { - if (volume > 0) { - this.lastVolume = volume; - } - const playerVolume: PlayerVolume = { muted: volume <= 0, volume: this.lastVolume }; - this._volume$.next(playerVolume); - } - public mute(): void { const playerVolume = { ...this._volume$.value, muted: true }; this._volume$.next(playerVolume); @@ -92,46 +98,48 @@ export class AudioPlayerService { } public toggleMute(): void { - if(this.muted){ - return this.unmute(); + const isMuted = this._volume$.value.muted; + if (isMuted) { + this.unmute(); + } else { + this.mute(); } - this.mute(); } + // Getter methods to expose Observables for external use public get src$(): Observable { - return this._src$.asObservable(); + return this._currentSound$.pipe(map(sound => sound?.src || '')); } + public get playing$(): Observable { return this._playing$.asObservable(); } + public get bpm$(): Observable { return this._bpm$.asObservable(); } + public get volume$(): Observable { return this._volume$.asObservable(); } public get src(): string { - return this._src$.value; + return this.player.src; } + public get playing(): boolean { return this._playing$.value; } + public get bpm(): number { return this._bpm$.value; } + public get volume(): PlayerVolume { return this._volume$.value; } + public get muted(): boolean { return this.player.muted; } - public get paused(): boolean { - return this.player.paused; - } -} - -interface PlayerVolume { - volume: number; - muted: boolean; } From 7237e49c7fcfe3302427ab94f88797da1646375f Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Tue, 12 Mar 2024 06:49:32 +0100 Subject: [PATCH 11/36] [feature-107] progress on playlists (lot of things, i don't remember all things i've done in this commit) --- src/main/ipcs/bs-playlist-ipcs.ts | 5 + src/main/ipcs/os-controls-ipcs.ts | 8 +- .../local-playlists-manager.service.ts | 37 ++++++- .../maps/local-maps-manager.service.ts | 37 +++---- src/main/services/ipc.service.ts | 5 +- .../available-versions-slide.component.tsx | 2 +- .../maps-playlists-panel.component.tsx | 11 ++- .../maps/local-maps-list-panel.component.tsx | 2 +- .../maps/map-item.component.tsx | 43 +++----- .../maps/maps-row.component.tsx | 2 +- .../local-playlists-list-panel.component.tsx | 53 +++++----- .../playlists/playlist-item.component.tsx | 70 +++++++++++-- .../download-maps-modal.component.tsx | 2 +- .../download-models-modal.component.tsx | 2 +- ...local-playlist-details-modal.component.tsx | 97 ++++++++++++++----- .../playlist-details-template.component.tsx | 4 +- .../share-folders-modal.component.tsx | 2 +- .../models-grid.component.tsx | 2 +- .../components/nav-bar/nav-bar.component.tsx | 2 +- .../shared/bsm-button.component.tsx | 26 +++-- .../shared/glow-effect.component.tsx | 6 +- .../svgs/icons/sync-icon.component.tsx | 8 +- .../svgs/icons/trash-icon.component.tsx | 10 +- src/renderer/components/svgs/svg-icon.type.ts | 2 +- src/renderer/hooks/use-observable.hook.ts | 2 +- src/renderer/index.css | 7 ++ .../pages/settings-page.component.tsx | 2 +- .../steam-downloader.service.ts | 2 - .../services/playlists-manager.service.ts | 4 + 29 files changed, 316 insertions(+), 139 deletions(-) diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 3d76da3d7..31adb7b80 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -44,3 +44,8 @@ ipc.on("get-version-playlists-details", (req, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); reply(playlists.getVersionPlaylistsDetails(req.args)); }); + +ipc.on<{path: string, deleteMaps?: boolean}>("delete-playlist", (req, reply) => { + const playlists = LocalPlaylistsManagerService.getInstance(); + reply(playlists.deletePlaylist(req.args)); +}); diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index a5b425252..d15eed284 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -11,14 +11,18 @@ import { from, of } from "rxjs"; const ipc = IpcService.getInstance(); -ipcMain.on("new-window", async (event, request: IpcRequest) => { +ipcMain.on("new-window", (event, request: IpcRequest) => { shell.openExternal(request.args); }); -ipc.on("choose-folder", async (req, reply) => { +ipc.on("choose-folder", (req, reply) => { reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: req.args ?? "" }))); }); +ipc.on("view-path-in-explorer", (req, reply) => { + reply(of(shell.showItemInFolder(req.args))); +}); + ipcMain.on("window.progression", async (event, request: IpcRequest) => { BrowserWindow.fromWebContents(event.sender)?.setProgressBar(request.args / 100); }); diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index 6cc43af01..243a1d350 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -11,13 +11,12 @@ import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; -import { Progression, pathExist } from "../../helpers/fs.helpers"; +import { Progression, pathExist, unlinkPath } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { SongCacheService } from "./maps/song-cache.service"; -import { pathToFileURL } from "url"; import { InstallationLocationService } from "../installation-location.service"; export class LocalPlaylistsManagerService { @@ -229,6 +228,40 @@ export class LocalPlaylistsManagerService { } + public deletePlaylist(opt: {path: string, deleteMaps?: boolean}): Observable{ + + console.log("AAAAAA"); + + return new Observable(obs => { + (async () => { + + const bpList = await this.readPlaylistFile(opt.path); + + const progress: Progression = { current: 0, total: opt.deleteMaps ? bpList.songs.length + 1 : 1}; + + if(opt.deleteMaps){ + const mapsHashs = bpList.songs.map(s => ({ hash: s.hash })); + await lastValueFrom(this.maps.deleteMaps(mapsHashs).pipe( + tap({ + next: () => { + progress.current += 1 + obs.next(progress); + }, + error: err => obs.error(err), + complete: () => obs.next(progress), + }), + )); + } + + await unlinkPath(opt.path); + progress.current += 1; + obs.next(progress); + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); + } + public oneClickInstallPlaylist(bpListUrl: string): Observable> { return new Observable>(obs => { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index 38e75f4ab..071b5af96 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -7,7 +7,7 @@ import { InstallationLocationService } from "../../installation-location.service import { UtilsService } from "../../utils.service"; import crypto from "crypto"; import { lstatSync } from "fs"; -import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra"; +import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra"; import StreamZip from "node-stream-zip"; import { RequestService } from "../../request.service"; import sanitize from "sanitize-filename"; @@ -240,32 +240,33 @@ export class LocalMapsManagerService { return this.linker.unlinkFolder(versionMapsPath, { keepContents: keepMaps, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER }); } - public deleteMaps(maps: BsmLocalMap[]): Observable { - const mapsFolders = maps.map(map => map.path); - const mapsHashsToDelete = maps.map(map => map.hash); - + public deleteMaps(maps: Partial[]): Observable { return new Observable(observer => { + const progress: DeleteMapsProgress = { total: maps.length, deleted: 0 }; + (async () => { - const progress: DeleteMapsProgress = { total: maps.length, deleted: 0 }; - try { - for (const folder of mapsFolders) { - const detail = await this.loadMapInfoFromPath(folder); - if (!mapsHashsToDelete.includes(detail?.hash)) { - continue; - } - await deleteFolder(folder); - this.songCache.deleteMapInfoFromDirname(path.basename(folder)); + for (const map of maps) { + let mapPath = map.path; + + if (!mapPath) { + const mapInfo = map.hash ? this.songCache.getMapInfoFromHash(map.hash) : null; + mapPath = mapInfo?.path; + } + + if (mapPath && pathExistsSync(mapPath)) { + await deleteFolder(mapPath); + this.songCache.deleteMapInfoFromDirname(path.basename(mapPath)); progress.deleted++; observer.next(progress); } - } catch (e) { - observer.error(e); } - observer.complete(); - })(); + })() + .catch(e => observer.error(e)) + .finally(() => observer.complete()); }); } + public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise { if (!map.versions.at(0).hash) { throw new Error("Cannot download map, no hash found"); diff --git a/src/main/services/ipc.service.ts b/src/main/services/ipc.service.ts index 926048836..d503d7a1e 100644 --- a/src/main/services/ipc.service.ts +++ b/src/main/services/ipc.service.ts @@ -54,10 +54,13 @@ export class IpcService { complete: () => this.send(this.getCompleteChannel(channel), window) }) - window.webContents.once("destroyed", () => sub.unsubscribe()); + const unsubscribeOnDestroy = () => sub.unsubscribe(); + + window.webContents.once("destroyed", unsubscribeOnDestroy); window.webContents.ipc.once(this.getTearDownChannel(channel), () => sub.unsubscribe()); sub.add(() => { + window.webContents.removeListener("destroyed", unsubscribeOnDestroy); window.webContents.ipc.removeAllListeners(this.getTearDownChannel(channel)); }); } diff --git a/src/renderer/components/available-versions/available-versions-slide.component.tsx b/src/renderer/components/available-versions/available-versions-slide.component.tsx index 89f9b3c4b..353cd3bcd 100644 --- a/src/renderer/components/available-versions/available-versions-slide.component.tsx +++ b/src/renderer/components/available-versions/available-versions-slide.component.tsx @@ -29,7 +29,7 @@ export function AvailableVersionsSlide({ versions }: Props) { } return ( -
      +
        {getVersions().map(version => ( setSelectedVersion(version)}/> ))} diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 8073f321d..48083ec44 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -19,13 +19,19 @@ import { LocalPlaylistsListPanel } from "./playlists/local-playlists-list-panel. import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { useConstant } from "renderer/hooks/use-constant.hook"; +import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; type Props = { version?: BSVersion; isActive?: boolean; }; -export const InstalledMapsContext = createContext<{ maps$?: BehaviorSubject; setMaps: (maps: BsmLocalMap[]) => void }>(null); +export const InstalledMapsContext = createContext<{ + maps$?: BehaviorSubject; + setMaps: (maps: BsmLocalMap[]) => void; + playlists$?: Observable; + setPlaylists: (playlist: LocalBPListsDetails[]) => void; +}>(null); export function MapsPlaylistsPanel({ version, isActive }: Props) { @@ -37,7 +43,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const [tabIndex, setTabIndex] = useState(0); const maps$ = useConstant(() => new BehaviorSubject(undefined)); - const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$) })); + const playlists$ = useConstant(() => new BehaviorSubject(undefined)); + const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$), playlists$: playlists$, setPlaylists: playlists$.next.bind(playlists$)})); const mapsRef = useRef(); const [mapFilter, setMapFilter] = useState({}); diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 567401d98..e35be457c 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -402,7 +402,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa return (
        - 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> + 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> {props => }
        diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 213157ebb..c9528431e 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -159,7 +159,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, {(diffsPanelHovered || bottomBarHovered) && ( - + {Array.from(diffs.entries()).map(([charac, diffSet]) => (
          {diffSet.map(({ type, name, stars }) => ( @@ -178,9 +178,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
          - + { e.stopPropagation(); @@ -188,12 +188,12 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, toogleMusic(); }} > - +
          - -
          + +

          {title} @@ -241,18 +241,16 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,

          -
          +
          -
          +
          {onDelete && !downloading && ( -
          { @@ -260,15 +258,13 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, onDelete(callBackParam); }} /> -
          )} {onDownload && !downloading && ( -
          -
          )} {onCancelDownload && !downloading && ( -
          -
          )} {downloading && @@ -306,10 +299,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, } {previewUrl && ( -
          -
          )} {mapId && ( -
          -
          )}
          diff --git a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx index 555b64525..19fbcfa0c 100644 --- a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx @@ -25,7 +25,7 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel const renderMapItem = (map: BsmLocalMap) => { return (({ version, cl const playlistService = useService(PlaylistsManagerService); const modals = useService(ModalService); + const ipc = useService(IpcService); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); - const { maps$ } = useContext(InstalledMapsContext); + const { maps$, playlists$, setPlaylists } = useContext(InstalledMapsContext); + + const playlists = useObservable(() => playlists$, []); + const [playlistsLoading, setPlaylistsLoading] = useState(false); - const [playlists, setPlaylists] = useState([]); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); @@ -52,7 +58,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl if(!isActiveOnce){ return noop(); } loadLocalPlaylistsDetails().then(loadedPlaylists => { - setPlaylists(() => loadedPlaylists); + setPlaylists(loadedPlaylists); }).catch(() => { setPlaylists([]); }).finally(() => { @@ -61,27 +67,27 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }, [isActiveOnce, version, linked]); - console.log(maps$.value); + const viewPlaylistFile = (path: string) => { + return lastValueFrom(ipc.sendV2("view-path-in-explorer", { args: path })); + }; - const openPlaylistDetails = (playlistKey: string) => { - const playlist = playlists.find(p => p.path === playlistKey); + const deletePlaylist = (path: string) => { + // !! Need to call the modal to confirm the deletion and to ask if the maps should be deleted too + lastValueFrom(playlistService.deletePlaylist({ path, deleteMaps: false })).then(() => { + setPlaylists(playlists.filter(p => p.path !== path)); + }) + }; - console.log(playlist.songs); + const openPlaylistDetails = (playlistKey: string) => { + const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistKey))); + const installedMaps$ = combineLatest([maps$, localPlaylist$]).pipe( + filter(([maps, playlist]) => !!maps && !!playlist), + map(([maps, playlist]) => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))), + distinctUntilChanged(equal) + ); modals.openModal(LocalPlaylistDetailsModal, { - data: { - version, - title: playlist.playlistTitle, - image: playlist.image, - author: playlist.playlistAuthor, - description: playlist.playlistDescription, - nbMaps: playlist.nbMaps, - duration: playlist.duration, - maxNps: playlist.maxNps, - minNps: playlist.minNps, - nbMappers: playlist.nbMappers, - installedMaps$: maps$.pipe(map(maps => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase())))), - }, + data: { version, localPlaylist$, installedMaps$ }, noStyle: true, }) }; @@ -93,7 +99,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl ) } - if (playlists.length){ + if (playlists?.length){ return (
            {playlists.map(p => @@ -108,6 +114,9 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl maxNps={p.maxNps} minNps={p.minNps} onClickOpen={() => openPlaylistDetails(p.path)} + onClickDelete={() => deletePlaylist(p.path)} + onClickSync={() => console.log("sync")} + onClickOpenFile={() => viewPlaylistFile(p.path)} /> )}
          diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index 3d04855ad..2c35127d8 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -9,6 +9,8 @@ import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component'; import { GlowEffect } from 'renderer/components/shared/glow-effect.component'; import { useState } from 'react'; import { SearchIcon } from 'renderer/components/svgs/icons/search-icon.component'; +import { BsmButton } from 'renderer/components/shared/bsm-button.component'; +import Tippy from '@tippyjs/react'; type Props = { title?: string; @@ -21,10 +23,28 @@ type Props = { minNps?: number; maxNps?: number; selected?: boolean; + path?: string; onClickOpen?: () => void; + onClickOpenFile?: () => void; + onClickDelete?: () => void; + onClickSync?: () => void; } -export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected, onClickOpen }: Props) { +export function PlaylistItem({ title, + author, + coverUrl, + coverBase64, + duration, + nbMaps, + nbMappers, + minNps, + maxNps, + selected, + onClickOpen, + onClickOpenFile, + onClickSync, + onClickDelete +}: Props) { const color = useThemeColor("first-color"); @@ -48,18 +68,17 @@ export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, n // TODO : Translate return ( - setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
          -
          - -
          +
          + +
          @@ -75,6 +94,43 @@ export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, n
          +
          + + + +
          + {onClickSync && + + } + {onClickOpenFile && + + } + {onClickDelete && + + } + +
          +
          diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 3ed27f686..e63167534 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -206,7 +206,7 @@ export const DownloadMapsModal: ModalComponent handleSortChange(sort)} />
          -
            +
              {maps.length === 0 ? (
               diff --git a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx index 41a0033b3..42982b8f2 100644 --- a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx @@ -217,7 +217,7 @@ export const DownloadModelsModal: ModalComponent currentSort$.next(value)} />
              -
                +
                  {msModels.length === 0 ? (
                   diff --git a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx index 87eed38dc..342e0e6ea 100644 --- a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx @@ -8,9 +8,15 @@ import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item. import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component"; import { useService } from "renderer/hooks/use-service.hook"; import { AudioPlayerService } from "renderer/services/audio-player.service"; +import { AnimatePresence, motion } from "framer-motion"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; -interface Props extends Omit { - version: BSVersion +interface Props { + version: BSVersion; + localPlaylist$: Observable; installedMaps$: Observable; } @@ -18,7 +24,8 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const audioPlayer = useService(AudioPlayerService); - const installedMaps = useObservable(() => options.data.installedMaps$, undefined); + const localPlaylist = useObservable(() => options.data.localPlaylist$, null); + const installedMaps = useObservable(() => options.data.installedMaps$, null); const playPlaylist = () => { if (!installedMaps) { @@ -29,37 +36,75 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const renderMaps = () => { if (!installedMaps) { + // loading maps + return null; + } + + if(installedMaps.length === 0) { + // no maps return null; } return ( -
                    - {installedMaps.map(map => ( - - ))} -
                  +
                  + + {/* If nb installed maps not correspond to nb maps of the playlist */} + {installedMaps.length !== localPlaylist.nbMaps && ( + +
                  + +
                  +

                  Certaines maps de cette playlist sont manquantes

                  + +
                  +
                  +
                  + )} +
                  +
                    + {installedMaps.map(map => ( + + ))} +
                  +
                  ) } return ( - + {renderMaps()} ) diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx index 7046abe5e..9f154204f 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx @@ -41,8 +41,8 @@ export function PlaylistDetailsTemplate({title, image, author, description, nbMa })(); return ( -
                  -
                  +
                  +
                  diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx index 7372bb8d3..e098e3e0f 100644 --- a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -73,7 +73,7 @@ export const ShareFoldersModal: ModalComponent = ({ options: {d

                  {t("modals.shared-folders.title")}

                  {t("modals.shared-folders.description")}

                  -
                    +
                      {folders.map((folder, index) => ( (({ className, version, type } return ( -
                        +
                          {filtredModels().map(localModel => ( m.hash === localModel.hash)} onClick={() => handleModelClick(localModel)} onDelete={() => handleDelete(localModel)} /> ))} diff --git a/src/renderer/components/nav-bar/nav-bar.component.tsx b/src/renderer/components/nav-bar/nav-bar.component.tsx index f3fcc58b1..295f2336f 100644 --- a/src/renderer/components/nav-bar/nav-bar.component.tsx +++ b/src/renderer/components/nav-bar/nav-bar.component.tsx @@ -38,7 +38,7 @@ export function NavBar() { return ( @@ -151,8 +171,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { ]} > - - + +
                  diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx new file mode 100644 index 000000000..db15466a0 --- /dev/null +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx @@ -0,0 +1,123 @@ +import { motion } from "framer-motion" +import { Dispatch, SetStateAction, useState } from "react"; +import { BsmRange } from "renderer/components/shared/bsm-range.component"; +import { cn } from "renderer/helpers/css-class.helpers" +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import dateFormat from "dateformat"; +import { hourToS, sToMs } from "shared/helpers/time.helpers"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; + +type Props = { + className?: string; + filter?: LocalPlaylistFilter; + onChange?: (filter: LocalPlaylistFilter) => void; +} + +const [MIN_NB_MAPS, MAX_NB_MAPS] = [0, 1000]; +const [MIN_NB_MAPPER, MAX_NB_MAPPER] = [0, 1000]; +const [MIN_DURATION, MAX_DURATION] = [0, hourToS(9)]; +const [MIN_NPS, MAX_NPS] = [0, 17]; + +console.log(hourToS(9)); + +export function LocalPlaylistFilterPanel({ className, filter, onChange }: Props) { + + const t = useTranslation(); + + const [minNps, setMinNps] = useState(filter?.minNps ?? MIN_NPS); + const [maxNps, setMaxNps] = useState(filter?.maxNps ?? MAX_NPS); + + const [minNbMaps, setMinNbMaps] = useState(filter?.minNbMaps ?? MIN_NB_MAPS); + const [maxNbMaps, setMaxNbMaps] = useState(filter?.maxNbMaps ?? MAX_NB_MAPS); + + const [minNbMappers, setMinNbMappers] = useState(filter?.minNbMappers ?? MIN_NB_MAPPER); + const [maxNbMappers, setMaxNbMappers] = useState(filter?.minNbMappers ?? MAX_NB_MAPPER); + + const [minDuration, setMinDuration] = useState(filter?.minDuration ?? MIN_DURATION); + const [maxDuration, setMaxDuration] = useState(filter?.maxDuration ?? MAX_DURATION); + + useOnUpdate(() => { + + if(!onChange){ return; } + + const filter: LocalPlaylistFilter = { + minNps: minNps <= MIN_NPS ? undefined : minNps, + maxNps: maxNps >= MAX_NPS ? undefined : maxNps, + minNbMaps: minNbMaps <= MIN_NB_MAPS ? undefined : minNbMaps, + maxNbMaps: maxNbMaps >= MAX_NB_MAPS ? undefined : maxNbMaps, + minNbMappers: minNbMappers <= MIN_NB_MAPPER ? undefined : minNbMappers, + maxNbMappers: maxNbMappers >= MAX_NB_MAPPER ? undefined : maxNbMappers, + minDuration: minDuration <= MIN_DURATION ? undefined : minDuration, + maxDuration: maxDuration >= MAX_DURATION ? undefined : maxDuration, + }; + + onChange(filter); + + }, [minNps, maxNps, minNbMaps, maxNbMaps, minNbMappers, maxNbMappers, minDuration, maxDuration]); + + const handleRangeChange = ([minSetter, maxSetter]: Dispatch>[], [min, max]: number[], absoluteMin: number, absoluteMax: number) => { + minSetter(() => min <= absoluteMin ? undefined : min); + maxSetter(() => max >= absoluteMax ? undefined : max); + }; + + const handleOnNpsChange = (minMax: number[]) => handleRangeChange([setMinNps, setMaxNps], minMax, MIN_NPS, MAX_NPS); + const handleOnNbMapsChange = (minMax: number[]) => handleRangeChange([setMinNbMaps, setMaxNbMaps], minMax, MIN_NB_MAPS, MAX_NB_MAPS); + const handleOnNbMapperChange = (minMax: number[]) => handleRangeChange([setMinNbMappers, setMaxNbMappers], minMax, MIN_NB_MAPPER, MAX_NB_MAPPER); + const handleOnDurationChange = (minMax: number[]) => handleRangeChange([setMinDuration, setMaxDuration], minMax, MIN_DURATION, MAX_DURATION); + + const renderLabel = (text: string | number, isMax: boolean): JSX.Element => { + return {text}; + }; + + const renderSimpleMinMaxLabel = (value: number, max: number) => { + const label = value >= max ? `∞` : value; + return renderLabel(label, label === "∞"); + } + + const renderDurationLabel = (sec: number): JSX.Element => { + const textValue = (() => { + if (sec === MIN_DURATION) { + return MIN_DURATION; + } + if (sec === MAX_DURATION) { + return "∞"; + } + + return sec > 3600 ? dateFormat(sToMs(sec), "h:MM:ss") : dateFormat(sToMs(sec), "MM:ss"); + })(); + + return renderLabel(textValue, sec === MAX_DURATION); + }; + + return ( + +
                  + renderSimpleMinMaxLabel(v, MAX_NB_MAPS)}/> + Nombre de maps +
                  +
                  + renderSimpleMinMaxLabel(v, MAX_NB_MAPPER)}/> + Nombre de mappeurs +
                  +
                  + + Durée +
                  +
                  + renderSimpleMinMaxLabel(v, MAX_NPS)}/> + Notes par secondes +
                  +
                  + ) +} + +export type LocalPlaylistFilter = Partial<{ + minNps: number; + maxNps: number; + minNbMaps: number; + maxNbMaps: number; + minNbMappers: number; + maxNbMappers: number; + minDuration: number; + maxDuration: number; +}> diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index 6e175781c..b4340c576 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useContext, useState } from "react"; +import { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from "react"; import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; @@ -6,7 +6,7 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; -import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; +import { BehaviorSubject, combineAll, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; @@ -19,43 +19,130 @@ import { IpcService } from "renderer/services/ipc.service"; import equal from "fast-deep-equal"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; -import { ProgressBarService } from "renderer/services/progress-bar.service"; import { NotificationService } from "renderer/services/notification.service"; import { DeletePlaylistModal } from "renderer/components/modal/modal-types/playlist/delete-playlist-modal.component"; import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { PlaylistItemComponentPropsMapper } from "shared/mappers/playlist/playlist-item-component-props.mapper"; import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; +import { LocalPlaylistFilter } from "./local-playlist-filter-panel.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { DownloadPlaylistModal } from "renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component"; +import { logRenderError } from "renderer"; +import { tryit } from "shared/helpers/error.helpers"; +import { ProgressBarService } from "renderer/services/progress-bar.service"; +import { ProgressionInterface } from "shared/models/progress-bar"; +import { enumerate } from "shared/helpers/array.helpers"; +import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component"; +import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component"; type Props = { version: BSVersion; className?: string; + filter?: LocalPlaylistFilter; + search?: string; linkedState?: FolderLinkState; isActive?: boolean; }; -// TODO : Translate +export type LocalPlaylistsListRef = { + syncPlaylists: () => Promise; + deletePlaylists: () => Promise; + exportPlaylists: () => Promise; +} -export const LocalPlaylistsListPanel = forwardRef(({ version, className, isActive, linkedState }, forwardedRef) => { +export const LocalPlaylistsListPanel = forwardRef(({ version, className, filter: playlistFiler, search, isActive, linkedState }, forwardedRef) => { + const t = useTranslation(); + + const progess = useService(ProgressBarService); const playlistService = useService(PlaylistsManagerService); const playlistDownloader = useService(PlaylistDownloaderService); const modals = useService(ModalService); const ipc = useService(IpcService); - const progress = useService(ProgressBarService); const osDiagnostic = useService(OsDiagnosticService); const notification = useService(NotificationService); const isOnline = useObservable(() => osDiagnostic.isOnline$, false); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); - const { maps$, playlists$, setPlaylists } = useContext(InstalledMapsContext); + const { maps$, playlists$, setPlaylists, setMaps } = useContext(InstalledMapsContext); + const selectedPlaylists$ = useConstant(() => new BehaviorSubject([])); const playlists = useObservable(() => playlists$, []); + console.log(playlists); + const [playlistsLoading, setPlaylistsLoading] = useState(false); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); + const installPlaylist = (playlist: LocalBPList) => { + const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLocaleLowerCase()); + return playlistDownloader.downloadPlaylist({ downloadSource: playlist.customData?.syncURL ?? playlist.path, version, ignoreSongsHashs, dest: playlist.path }); + } + + useImperativeHandle(forwardedRef, () => ({ + syncPlaylists: async () => { + if(!isOnline){ return; } + const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; + if(!toSync.length){ return; } + + const modalRes = await modals.openModal(SyncPlaylistModal, { data: toSync }); + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const obs$ = combineLatest(toSync.map(playlist => installPlaylist(playlist))); + + const { error, result } = await tryit(() => lastValueFrom(obs$)); + + if(error){ + logRenderError("Error occured while synchronizing playlists", error); + notification.notifyError({ title: "Erreur lors de la synchronisation des playlists", desc: "Une erreur est survenue lors de la synchronisation des playlists." }); + return; + } + + if(result.every(res => res.current === res.total)){ + notification.notifySuccess({ title: "Playlists synchronisées !", desc: "Les playlists et leurs maps ont été téléchargées.", duration: 5000 }); + } + }, + exportPlaylists: async () => { + if(!progess.require()){ return; } + const toExport = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; + + if(!toExport.length){ return; } + + const modalRes = await modals.openModal(ExportPlaylistModal, { data: toExport }); + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const folderRes = await lastValueFrom(ipc.sendV2("choose-folder")); + if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; } + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), exportMaps: modalRes.data }); + + progess.show(obs$, true); + + const { error } = await tryit(() => lastValueFrom(obs$)); + + if(error){ + logRenderError("Error occured while exporting playlists", error); + notification.notifyError({ title: "Erreur lors de l'exportation des playlists", desc: "Une erreur est survenue lors de l'exportation des playlists." }); + return; + } + + notification.notifySuccess({ title: "Playlists exportées !", desc: "Les playlists et leurs maps ont été exportées.", duration: 5000 }); + + progess.hide(true); + }, + deletePlaylists: () => { + const toDelete = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; + if(!toDelete.length){ return Promise.resolve(); } + return deletePlaylists(toDelete); + } + })) + const loadLocalPlaylistsDetails = (): Promise => { setPlaylistsLoading(true); const obs = playlistService.getVersionPlaylistsDetails(version).pipe( @@ -101,11 +188,13 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }, [isActiveOnce, version, linked]); - const installPlaylist = (playlist: LocalBPList) => { + const openDownloadPlaylistModal = () => { + modals.openModal(DownloadPlaylistModal, { data: { version, ownedPlaylists$: playlists$, ownedMaps$: maps$ } }); + } - const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLocaleLowerCase()); + const handleClickSync = (playlist: LocalBPList) => { - const obs$ = playlistDownloader.downloadPlaylist({ downloadSource: playlist.customData?.syncURL ?? playlist.path, version, ignoreSongsHashs, dest: playlist.path }); + const obs$ = installPlaylist(playlist); return lastValueFrom(obs$).then(res => { if(res.current === res.total){ @@ -118,14 +207,37 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl return lastValueFrom(ipc.sendV2("view-path-in-explorer", path)); }; - const deletePlaylist = async (bpList: LocalBPList) => { - const { exitCode, data: deleteMaps } = await modals.openModal(DeletePlaylistModal, { data: bpList }); + const deletePlaylists = async (bpLists: LocalBPList[]) => { + + if(!bpLists.length || !progess.require()){ return; } + + const { exitCode, data: deleteMaps } = await modals.openModal(DeletePlaylistModal, { data: bpLists }); if(exitCode !== ModalExitCode.COMPLETED){ return; } - lastValueFrom(playlistService.deletePlaylist({ version, bpList, deleteMaps })).then(() => { + const progess$ = new BehaviorSubject({ progression: 0 }); + progess.show(progess$, true) + + for(const [i, bpList] of enumerate(bpLists)){ + + const { error } = await tryit(() => lastValueFrom(playlistService.deletePlaylist({ version, bpList, deleteMaps }))) + + if(error){ + logRenderError("Error occured while deleting playlist", error); + notification.notifyError({ title: "Erreur lors de la suppression de la playlist", desc: "Une erreur est survenue lors de la suppression de la playlist." }); + progess.hide(true); + return; + } + + progess$.next({ progression: (i / bpLists.length) * 100, label: bpList.playlistTitle }); setPlaylists(playlists$.value.filter(p => p.path !== bpList.path)); - }) + + if(deleteMaps){ + setMaps(maps$.value.filter(m => !bpList.songs.some(s => s.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))); + } + } + + progess.hide(true); }; const openPlaylistDetails = (playlistPath: string) => { @@ -143,31 +255,72 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }; const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => { + + return ( selected.some(s => s.path === playlist.path)), distinctUntilChanged(equal))} + onClick={() => { + console.log(selectedPlaylists$.value, playlist.path); + if(selectedPlaylists$.value.some(s => s.path === playlist.path)){ + selectedPlaylists$.next(selectedPlaylists$.value.filter(s => s.path !== playlist.path)); + return; + } + + selectedPlaylists$.next([...selectedPlaylists$.value, playlist]); + }} onClickOpen={() => openPlaylistDetails(playlist.path)} - onClickDelete={() => deletePlaylist(playlist)} - onClickSync={isOnline && (() => installPlaylist(playlist))} + onClickDelete={() => deletePlaylists([playlist])} + onClickSync={isOnline && (() => handleClickSync(playlist))} onClickOpenFile={() => viewPlaylistFile(playlist.path)} onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)} /> ); }, [isOnline, version]); + const filteredPlaylists = useMemo(() => { + if(!playlists){ return []; } + + return playlists.filter(p => { + if(!p.playlistTitle.toLocaleLowerCase().includes(search.toLocaleLowerCase())){ return false; } + if(!p.playlistAuthor.toLocaleLowerCase().includes(search.toLocaleLowerCase())){ return false; } + + if(typeof p.nbMaps === "number" && (typeof playlistFiler?.minNbMaps === "number" || typeof playlistFiler?.maxNbMaps === "number")){ + if(playlistFiler?.minNbMaps && p.nbMaps < playlistFiler.minNbMaps){ return false; } + if(playlistFiler?.maxNbMaps && p.nbMaps > playlistFiler.maxNbMaps){ return false; } + } + + if(typeof p.nbMappers === "number" && (typeof playlistFiler?.minNbMappers === "number" || typeof playlistFiler?.maxNbMappers === "number")){ + if(playlistFiler?.minNbMappers && p.nbMappers < playlistFiler.minNbMappers){ return false; } + if(playlistFiler?.maxNbMappers && p.nbMappers > playlistFiler.maxNbMappers){ return false; } + } + + if(typeof p.duration === "number" && (typeof playlistFiler?.minDuration === "number" || typeof playlistFiler?.maxDuration === "number")){ + if(playlistFiler?.minDuration && p.duration < playlistFiler.minDuration){ return false; } + if(playlistFiler?.maxDuration && p.duration > playlistFiler.maxDuration){ return false; } + } + + if(typeof p.minNps === "number" && typeof playlistFiler.minNps === "number" && p.minNps < playlistFiler.minNps){ return false; } + if(typeof p.maxNps === "number" && typeof playlistFiler.maxNps === "number" && p.maxNps > playlistFiler.maxNps){ return false; } + + return true; + }); + }, [playlists, search, playlistFiler]); + return (
                  {(() => { if(playlistsLoading){ return ( - + ) } - if (playlists?.length){ + if (filteredPlaylists?.length){ return ( (({ version, cl itemHeight={120} maxColumns={4} minItemWidth={390} - items={playlists} + items={filteredPlaylists} renderItem={renderPlaylist} - rowKey={rowPlaylists => rowPlaylists.map(p => p.path).join("-")} /> ) } - return TODO; + return ( +
                  + + Aucune playlist + { + e.preventDefault(); + openDownloadPlaylistModal(); + }} + /> +
                  + ); })()}
                  ) diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index 19a58fc5f..01b66b57c 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -25,9 +25,10 @@ export type PlaylistItemComponentProps = { duration?: number; minNps?: number; maxNps?: number; - selected?: boolean; + selected$?: Observable; isDownloading$?: Observable; isInQueue$?: Observable; + onClick?: () => void; onClickOpen?: () => void; onClickOpenFile?: () => void; onClickDelete?: () => void; @@ -45,9 +46,10 @@ export function PlaylistItem({ title, nbMappers, minNps, maxNps, - selected, + selected$, isDownloading$, isInQueue$, + onClick, onClickOpen, onClickOpenFile, onClickSync, @@ -59,6 +61,7 @@ export function PlaylistItem({ title, const color = useThemeColor("first-color"); const [hovered, setHovered] = useState(false); + const selected = useObservable(() => selected$ ?? of(false), false, [selected$]); const isDownloading = useObservable(() => isDownloading$ ?? of(), false, [isDownloading$]); const isInQueue = useObservable(() => isInQueue$ ?? of(), false, [isInQueue$]); @@ -80,9 +83,9 @@ export function PlaylistItem({ title, // TODO : Translate return ( - setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)}> -
                  +
                  {e.stopPropagation(); onClick?.()}}>
                  @@ -91,7 +94,7 @@ export function PlaylistItem({ title, {e.stopPropagation(); onClickOpen?.()}} />
                  @@ -106,7 +109,7 @@ export function PlaylistItem({ title,
                  - + e.stopPropagation()}> diff --git a/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx index 6dc6ee265..eeb34eed6 100644 --- a/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx @@ -8,20 +8,32 @@ import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.pn import { BPList } from "shared/models/playlists/playlist.interface"; import Tippy from "@tippyjs/react"; -export const DeletePlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { +// TODO : Translate + +export const DeletePlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { const t = useTranslation(); const [deleteMaps, setDeleteMaps] = useState(false); + const isMultiple = data.length > 1; return (
                  -

                  Supprimer la playlist ?

                  + {!isMultiple ? ( +

                  Supprimer la playlist ?

                  + ) : ( +

                  Supprimer les playlists ?

                  + )} -

                  {`Est-tu sûr de vouloir supprimer la playlist "${data.playlistTitle}" ?`}

                  + {!isMultiple ? ( +

                  {`Est-tu sûr de vouloir supprimer la playlist "${data.at(0)?.playlistTitle}" ?`}

                  + ) : ( +

                  {`Est-tu sûr de vouloir supprimer les ${data.length} playlists ?`}

                  + )} +
                  setDeleteMaps(() => val)} /> - + Supprimer les maps
                  diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx index ed837b674..be65b51ca 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx @@ -111,7 +111,7 @@ export function DownloadPlaylistFilterPanel({ className, params, onChange, onSub }; return ( - +

                  {t("maps.map-filter-panel.specificities")}

                  diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx index 6b0bbc4fa..71cdf4b0b 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx @@ -54,9 +54,9 @@ export function DownloadPlaylistModalHeader({ className, value, onSubmit }: Prop - setQuery(e.target.value)} /> - submit(searchParams)} /> - + setQuery(e.target.value)} /> + submit(searchParams)} /> + ) } diff --git a/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx new file mode 100644 index 000000000..dad1b4fa7 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { BPList } from "shared/models/playlists/playlist.interface"; +import Tippy from "@tippyjs/react"; + +// TODO : Translate + +export const ExportPlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { + + const t = useTranslation(); + + const [exportMaps, setExportMaps] = useState(false); + const isMultiple = data.length > 1; + + return ( +
                  + {!isMultiple ? ( +

                  Exporter la playlist ?

                  + ) : ( +

                  Exporter les playlists ?

                  + )} + + {!isMultiple ? ( +

                  {`Est-tu sûr de vouloir exporter la playlist "${data.at(0)?.playlistTitle}" ?`}

                  + ) : ( +

                  {`Est-tu sûr de vouloir exporter les ${data.length} playlists ?`}

                  + )} + +
                  + setExportMaps(() => val)} /> + + Exporter les maps + +
                  +
                  + resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED, data: exportMaps })} withBar={false} text="Exporter" /> +
                  + + ); +}; diff --git a/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx new file mode 100644 index 000000000..f466c6e1d --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx @@ -0,0 +1,37 @@ +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { BPList } from "shared/models/playlists/playlist.interface"; + +// TODO : Translate + +export const SyncPlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { + + const t = useTranslation(); + + return ( +
                  + {data.length === 1 ? ( +

                  Synchroniser la playlist ?

                  + ) : ( +

                  Synchroniser les playlists ?

                  + )} + + + {data.length === 1 ? ( +

                  {`Est-tu sûr de vouloir synchroniser la playlist "${data.at(0)?.playlistTitle}" ?`}

                  + ) : ( +

                  {`Est-tu sûr de vouloir synchroniser les ${data.length} playlists ?`}

                  + )} + +

                  Cette action met à jour les playlists et télécharge les maps manquantes; cela peut durer plusieurs minutes.

                  + +
                  + resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="Synchroniser" /> +
                  + + ); +}; diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index 30e5c23d2..ce9c60cfc 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -28,7 +28,7 @@ type Props = { itemHeight: number; items: T[]; renderItem: (item: T) => JSX.Element; - rowKey: (rowItems: T[]) => Key; + rowKey?: (rowItems: T[]) => Key; scrollEnd?: ScrollEndHandler; } diff --git a/src/renderer/index.css b/src/renderer/index.css index 0c5cb0b4c..ca0ee076b 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -103,12 +103,12 @@ @apply border-r-neutral-900; } -.theme-color-1 { @apply bg-light-main-color-1 dark:bg-main-color-1; } -.theme-color-2 { @apply bg-light-main-color-2 dark:bg-main-color-2; } -.theme-color-3 { @apply bg-light-main-color-3 dark:bg-main-color-3; } -.\!theme-color-1 { @apply !bg-light-main-color-1 dark:!bg-main-color-1; } -.\!theme-color-2 { @apply !bg-light-main-color-2 dark:!bg-main-color-2; } -.\!theme-color-3 { @apply !bg-light-main-color-3 dark:!bg-main-color-3; } +.bg-theme-1 { @apply bg-light-main-color-1 dark:bg-main-color-1; } +.bg-theme-2 { @apply bg-light-main-color-2 dark:bg-main-color-2; } +.bg-theme-3 { @apply bg-light-main-color-3 dark:bg-main-color-3; } +.\!bg-theme-1 { @apply !bg-light-main-color-1 dark:!bg-main-color-1; } +.\!bg-theme-2 { @apply !bg-light-main-color-2 dark:!bg-main-color-2; } +.\!bg-theme-3 { @apply !bg-light-main-color-3 dark:!bg-main-color-3; } @keyframes glowing { 0% { diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index f6acf7129..244b002e8 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -1,6 +1,6 @@ import { BSVersion } from "shared/bs-version.interface"; import { IpcService } from "./ipc.service"; -import { Observable, lastValueFrom } from "rxjs"; +import { Observable, lastValueFrom, of, switchMap } from "rxjs"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; import { Progression } from "main/helpers/fs.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; @@ -38,6 +38,15 @@ export class PlaylistsManagerService { return this.ipc.sendV2("delete-playlist", opt); } + public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable> { + return this.ipc.sendV2("export-playlists", { + version: opt.version, + bpLists: opt.bpLists, + dest: opt.dest, + exportMaps: opt.exportMaps + }); + } + public async linkVersion(version: BSVersion): Promise { const modalRes = await this.modal.openModal(LinkPlaylistModal); diff --git a/src/shared/helpers/array.helpers.ts b/src/shared/helpers/array.helpers.ts index 58927f0bd..a7e0a024c 100644 --- a/src/shared/helpers/array.helpers.ts +++ b/src/shared/helpers/array.helpers.ts @@ -23,3 +23,9 @@ export function removeIndex(index: number, arr: T[]): T[] { arr.splice(index, 1); return arr; } + +export function* enumerate(arr: T[]): Generator<[number, T]> { + for (let i = 0; i < arr.length; i++) { + yield [i, arr[i]]; + } +} diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 55dec036e..9f00e1694 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -84,6 +84,7 @@ export interface IpcChannelMapping { "download-playlist": {request: {downloadSource: string, dest?: string, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression}; "get-version-playlists-details": {request: BSVersion, response: Progression}; "delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression}; + "export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}, response: Progression}; /* ** bs-uninstall-ipcs ** */ "bs.uninstall": { request: BSVersion, response: boolean }; From 15ebe8e92912f66057cc6993ba750b304a4c8201 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:22:52 +0200 Subject: [PATCH 24/36] [feature-107] minor fixes + use virtual scroll in playlists details view --- .../local-playlists-manager.service.ts | 64 +++----- .../maps/local-maps-manager.service.ts | 4 +- .../local-playlists-list-panel.component.tsx | 13 +- .../download-maps-modal.component.tsx | 2 +- .../bsv-playlist-details-modal.component.tsx | 148 +++++++++++++----- ...local-playlist-details-modal.component.tsx | 62 +++++--- .../services/playlists-manager.service.ts | 5 +- src/shared/models/ipc/ipc-routes.ts | 2 +- 8 files changed, 183 insertions(+), 117 deletions(-) diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index 2302456a1..ea2710c86 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -1,5 +1,5 @@ import path from "path"; -import { Observable, Subject, from, lastValueFrom, mergeMap, take, takeUntil, tap } from "rxjs"; +import { Observable, Subject, from, lastValueFrom, takeUntil, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { BSLocalVersionService } from "../bs-local-version.service"; import { DeepLinkService } from "../deep-link.service"; @@ -7,11 +7,11 @@ import { RequestService } from "../request.service"; import { LocalMapsManagerService } from "./maps/local-maps-manager.service"; import log from "electron-log"; import { WindowManagerService } from "../window-manager.service"; -import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/models/playlists/playlist.interface"; +import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; -import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFile, writeFileSync } from "fs-extra"; -import { Progression, ensurePathNotAlreadyExist, ensurePathNotAlreadyExistSync, pathExist, unlinkPath } from "../../helpers/fs.helpers"; +import { copy, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFileSync } from "fs-extra"; +import { Progression, unlinkPath } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; @@ -20,9 +20,9 @@ import { SongCacheService } from "./maps/song-cache.service"; import { InstallationLocationService } from "../installation-location.service"; import sanitize from "sanitize-filename"; import { isValidUrl } from "shared/helpers/url.helpers"; -import { allSettled } from "shared/helpers/promise.helpers"; import { Archive } from "main/models/archive.class"; import { CustomError } from "shared/models/exceptions/custom-error.class"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -291,7 +291,7 @@ export class LocalPlaylistsManagerService { return from(unlinkPath(bpList.path)); } - public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable> { + public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}): Observable> { if(!pathExistsSync(opt.dest)) { throw new CustomError(`Destination folder not found ${opt.dest}`, "DEST_ENOENT"); @@ -310,55 +310,31 @@ export class LocalPlaylistsManagerService { for(const bpList of opt.bpLists) { if(!pathExistsSync(bpList.path)) { - throw new CustomError(`Playlist file not found ${bpList.path}`, "PLAYLIST_ENOENT"); + log.warn(`Playlist file not found for export`, bpList.path); + continue; } archive.addFile(bpList.path, path.join(this.PLAYLISTS_FOLDER, path.basename(bpList.path))); } - if(!opt.exportMaps) { + if(!Array.isArray(opt.playlistsMaps) || opt.playlistsMaps.length === 0){ return archive.finalize(); } - const mapsHashsToExport = Array.from( - new Set(opt.bpLists.reduce((acc, bpList) => acc.concat((bpList.songs ?? []).map(s => s.hash)), [])).values() - ); + for(const map of opt.playlistsMaps) { - const zipMaps$ = new Observable>(obs => { - (async () => { - const progress: Progression = { total: mapsHashsToExport.length, current: 0, data: zipDest }; - - for(const hash of mapsHashsToExport) { - const mapInfo = await this.maps.getMapInfoFromHash(hash, opt.version); - - if(!mapInfo || !pathExistsSync(mapInfo.path)) { continue; } - - archive.addDirectory( - mapInfo.path, - path.join("Maps", path.basename(mapInfo.path)) // Dont't know why, but "CustomLevels" not work - ); - progress.current += 1; - - obs.next(progress); - } - - })() - .catch(err => obs.error(err)) - .finally(() => obs.complete()); - }); - - return new Observable>(obs => { - (async () => { - const maps$ = zipMaps$.pipe(tap({ next: p => obs.next(p) })); - const archive$ = archive.finalize().pipe(tap({ next: p => obs.next(p) })); + if(!map?.path || !pathExistsSync(map.path)) { + log.warn(`Map file not found for playlist export`, map?.path); + continue; + } - await lastValueFrom(maps$); - await lastValueFrom(archive$); - })() - .catch(err => obs.error(err)) - .finally(() => obs.complete()); - }) + archive.addDirectory( + map.path, + path.join("Maps", path.basename(map.path)) // Dont't know why, but "CustomLevels" not work + ); + } + return archive.finalize(); } public oneClickInstallPlaylist(bpListUrl: string): Observable> { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index ca55f0844..757ee6a58 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -287,9 +287,9 @@ export class LocalMapsManagerService { const versionMapsPath = await this.getMapsFolderPath(version); const mapInfo = this.songCache.getMapInfoFromHash(hash); - const cachedMapPath = path.join(versionMapsPath, mapInfo.dirname); + const cachedMapPath = (versionMapsPath && mapInfo?.dirname) && path.join(versionMapsPath, mapInfo.dirname); - if(pathExistsSync(cachedMapPath)){ + if(cachedMapPath && pathExistsSync(cachedMapPath)){ return this.loadMapInfoFromPath(cachedMapPath); } diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index b4340c576..ed1e70aaf 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -53,6 +53,8 @@ export type LocalPlaylistsListRef = { exportPlaylists: () => Promise; } +// TODO : Translate + export const LocalPlaylistsListPanel = forwardRef(({ version, className, filter: playlistFiler, search, isActive, linkedState }, forwardedRef) => { const t = useTranslation(); @@ -113,14 +115,17 @@ export const LocalPlaylistsListPanel = forwardRef( if(!toExport.length){ return; } - const modalRes = await modals.openModal(ExportPlaylistModal, { data: toExport }); - if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + const { exitCode, data: exportMaps } = await modals.openModal(ExportPlaylistModal, { data: toExport }); + if(exitCode !== ModalExitCode.COMPLETED){ return; } const folderRes = await lastValueFrom(ipc.sendV2("choose-folder")); if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; } - if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } - const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), exportMaps: modalRes.data }); + const mapsToExport = exportMaps ? ( + maps$?.value?.filter(m => toExport.some(p => p.songs.some(s => s.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))) ?? [] + ) : []; + + const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), playlistsMaps: mapsToExport }); progess.show(obs$, true); diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index e364d458d..01e0199ef 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -60,7 +60,7 @@ export const DownloadMapsModal: ModalComponent { loadMaps(searchParams); diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/bsv-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/bsv-playlist-details-modal.component.tsx index 686fbbb91..5980d86f6 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/bsv-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/bsv-playlist-details-modal.component.tsx @@ -5,19 +5,19 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { Observable } from "rxjs"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service"; import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date"; -import { motion } from "framer-motion"; import { MapsDownloaderService } from "renderer/services/maps-downloader.service"; import equal from "fast-deep-equal"; import BeatWaiting from "../../../../../../../assets/images/apngs/beat-waiting.png" import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png" import { BsmImage } from "renderer/components/shared/bsm-image.component"; import { cn } from "renderer/helpers/css-class.helpers"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; type Props = { version: BSVersion; @@ -32,10 +32,12 @@ export const BsvPlaylistDetailsModal: ModalComponent = ({ resolver, const beatsaver = useService(BeatSaverService); const mapsDownloader = useService(MapsDownloaderService); + const currentMapDownload = useObservable(() => mapsDownloader.currentMapDownload$, null); const downloadingMaps = useObservable(() => mapsDownloader.mapsInQueue$, []); const installedMaps = useObservable(() => installedMaps$, null); const [page, setPage] = useState(0); const [playlistMaps, setPlaylistMaps] = useState(null); + const [downloadbleMaps, setDownloadbleMaps] = useState([]); const [error, setError] = useState(false); useOnUpdate(() => { @@ -44,17 +46,61 @@ export const BsvPlaylistDetailsModal: ModalComponent = ({ resolver, .catch(() => setError(() => true)); }, [page]); + + useEffect(() => { + const ownedMapHashs = installedMaps?.map(map => map.hash) ?? []; + + if(!Array.isArray(playlistMaps)){ + return setDownloadbleMaps([]); + } + + setDownloadbleMaps(() => playlistMaps.map(map => { + const isMapOwned = map.versions.some(version => ownedMapHashs.includes(version.hash)); + const isDownloading = map.id === currentMapDownload?.map?.id; + const inQueue = downloadingMaps.some(toDownload => equal(toDownload.version, version) && toDownload.map.id === map.id); + + return { map, isOwned: isMapOwned, idDownloading: isDownloading, isInQueue: inQueue }; + })); + }, [playlistMaps, currentMapDownload, downloadingMaps, installedMaps]) + + const renderMapItem = useCallback((downloadableMap: DownloadableMap) => { + const map = downloadableMap.map; + + const downloadable = !downloadableMap.isOwned && !downloadableMap.isInQueue; + const cancelable = downloadableMap.isInQueue && !downloadableMap.idDownloading; + + return mapsDownloader.addMapToDownload({version, map}))} + onCancelDownload={cancelable && (() => mapsDownloader.removeMapToDownload({version, map}))} + downloading={downloadableMap.idDownloading} + callBackParam={map} />; + }, [version]); + return ( resolver({ exitCode: ModalExitCode.CLOSED })} > {(() => { @@ -82,41 +128,63 @@ export const BsvPlaylistDetailsModal: ModalComponent = ({ resolver, } return ( -
                    - {playlistMaps.map(map => { - - const downloadingMap = downloadingMaps.at(0); - const isMapOwned = installedMaps?.some(installedMap => installedMap.hash === map.versions.at(0).hash); - const isMapInQueue = downloadingMaps.some(downloadingMap => downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash); - const isMapDownloading = (isMapInQueue && downloadingMap) ? equal(downloadingMap.version, version) && downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash : false; - - return ( - mapsDownloader.addMapToDownload({version, map}))} - onCancelDownload={isMapInQueue && !isMapDownloading && (() => mapsDownloader.removeMapToDownload({version, map}))} - callBackParam={map} - />); - })} - setPage(prev => prev + 1)} /> -
                  + setPage(prev => prev + 1), + margin: 100 + }} + /> + //
                    + // {playlistMaps.map(map => { + + // const downloadingMap = downloadingMaps.at(0); + // const isMapOwned = installedMaps?.some(installedMap => installedMap.hash === map.versions.at(0).hash); + // const isMapInQueue = downloadingMaps.some(downloadingMap => downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash); + // const isMapDownloading = (isMapInQueue && downloadingMap) ? equal(downloadingMap.version, version) && downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash : false; + + // return ( + // mapsDownloader.addMapToDownload({version, map}))} + // onCancelDownload={isMapInQueue && !isMapDownloading && (() => mapsDownloader.removeMapToDownload({version, map}))} + // callBackParam={map} + // />); + // })} + // setPage(prev => prev + 1)} /> + //
                  ) })()}
                  ) } + +type DownloadableMap = { + map: BsvMapDetail; + isOwned: boolean; + idDownloading: boolean; + isInQueue: boolean; +}; diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx index 71f050b47..9f08114a1 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx @@ -1,4 +1,4 @@ -import { ModalComponent } from "renderer/services/modale.service" +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service" import { PlaylistDetailsTemplate } from "./playlist-details-template.component" import { Observable, combineLatest, lastValueFrom, map, switchMap } from "rxjs" import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface" @@ -14,6 +14,8 @@ import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.mode import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; import { PlaylistHeaderState } from "./playlist-header-state.component"; import { useConstant } from "renderer/hooks/use-constant.hook"; +import { useCallback } from "react"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; // TODO : Translate @@ -58,6 +60,29 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver return lastValueFrom(obs$); } + const renderMapItem = useCallback((map: BsmLocalMap) => { + return ( + + ); + }, []); + const renderMaps = () => { if (!Array.isArray(installedMaps) && !isInQueue) { return ( @@ -98,28 +123,18 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver isPlaylistInQueue$={isPlaylistInQueue$} installPlaylist={installPlaylist} /> -
                    - {installedMaps.map(map => ( - - ))} -
                  +
                  ) } @@ -135,6 +150,7 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver nbMaps={localPlaylist?.nbMaps} nbMappers={localPlaylist?.nbMappers} title={localPlaylist?.playlistTitle} + onClose={() => resolver({ exitCode: ModalExitCode.CLOSED })} > {renderMaps()} diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index 244b002e8..c6d648974 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -7,6 +7,7 @@ import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local- import { ModalExitCode, ModalService } from "./modale.service"; import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component"; import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; export class PlaylistsManagerService { private static instance: PlaylistsManagerService; @@ -38,12 +39,12 @@ export class PlaylistsManagerService { return this.ipc.sendV2("delete-playlist", opt); } - public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable> { + public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}): Observable> { return this.ipc.sendV2("export-playlists", { version: opt.version, bpLists: opt.bpLists, dest: opt.dest, - exportMaps: opt.exportMaps + playlistsMaps: opt.playlistsMaps }); } diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 9f00e1694..984499ad5 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -84,7 +84,7 @@ export interface IpcChannelMapping { "download-playlist": {request: {downloadSource: string, dest?: string, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression}; "get-version-playlists-details": {request: BSVersion, response: Progression}; "delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression}; - "export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}, response: Progression}; + "export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}, response: Progression}; /* ** bs-uninstall-ipcs ** */ "bs.uninstall": { request: BSVersion, response: boolean }; From b1ee9f054d3f81b2adec05a0f036e7fe48113a28 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Thu, 13 Jun 2024 21:54:22 +0200 Subject: [PATCH 25/36] [feature-107] start modal to create and edit playlists --- assets/proto/song_details_cache_v1.proto | 35 ++-- src/main/ipcs/bs-maps-ipcs.ts | 20 ++ .../maps/song-details-cache.service.ts | 3 +- .../maps-playlists-panel.component.tsx | 1 + .../maps/filter-panel.component.tsx | 162 ++++++++++++++- .../maps/local-maps-list-panel.component.tsx | 173 +--------------- .../maps/map-item.component.tsx | 14 +- .../local-playlist-filter-panel.component.tsx | 2 - .../local-playlists-list-panel.component.tsx | 6 +- .../download-playlist-modal.component.tsx | 1 - .../edit-playlist-modal.component.tsx | 184 ++++++++++++++++++ ...local-playlist-details-modal.component.tsx | 9 +- .../shared/bsm-dropdown-button.component.tsx | 4 +- .../virtual-scroll.component.tsx | 15 +- src/renderer/services/maps-manager.service.ts | 5 + .../map/map-item-component-props.mapper.ts | 106 ++++++++++ src/shared/models/ipc/ipc-routes.ts | 3 +- .../raw-song-details-cache.model.ts | 3 +- .../raw-song-details-deserializer.class.ts | 9 +- .../song-details-cache.model.ts | 1 + 20 files changed, 537 insertions(+), 219 deletions(-) create mode 100644 src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx create mode 100644 src/shared/mappers/map/map-item-component-props.mapper.ts diff --git a/assets/proto/song_details_cache_v1.proto b/assets/proto/song_details_cache_v1.proto index 495a37342..4786b665a 100644 --- a/assets/proto/song_details_cache_v1.proto +++ b/assets/proto/song_details_cache_v1.proto @@ -17,22 +17,23 @@ message UploadersList { message SongDetails { uint32 idInt = 1; - string hash = 2; - uint32 duration = 3; - UploaderRef uploaderRef = 4; - uint32 uploadedAt = 5; - repeated MapTag tags = 6; - bool ranked = 7; - bool qualified = 8; - bool curated = 9; - bool rankedBL = 10; - bool nominatedBL = 11; - bool qualifiedBL = 12; - uint32 upVotes = 13; - uint32 downVotes = 14; - uint32 downloads = 15; - bool automapper = 16; - repeated Difficulty difficulties = 17; + repeated uint32 hashIndices = 2; + string name = 3; + uint32 duration = 4; + UploaderRef uploaderRef = 5; + uint32 uploadedAt = 6; + repeated MapTag tags = 7; + bool ranked = 8; + bool qualified = 9; + bool curated = 10; + bool rankedBL = 11; + bool nominatedBL = 12; + bool qualifiedBL = 13; + uint32 upVotes = 14; + uint32 downVotes = 15; + uint32 downloads = 16; + bool automapper = 17; + repeated Difficulty difficulties = 18; } message Difficulty { @@ -54,7 +55,7 @@ message Difficulty { } message UploaderRef { - uint32 uploaderRefIndex = 1; + uint32 uploader_ref_index = 1; bool verified = 2; } diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index b492d6f91..1aa1f8c4b 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -1,7 +1,10 @@ +import { SongCacheService } from "main/services/additional-content/maps/song-cache.service"; import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service"; import { IpcService } from "../services/ipc.service"; import { from, of, throwError } from "rxjs"; import { tryit } from "shared/helpers/error.helpers"; +import { SongDetailsCacheService } from "main/services/additional-content/maps/song-details-cache.service"; +import { SongDetails } from "shared/models/maps"; const ipc = IpcService.getInstance(); @@ -62,3 +65,20 @@ ipc.on("is-map-deep-links-enabled", (_, reply) => { reply(of(result)); }); + +ipc.on("get-maps-info-from-cache", (args, reply) => { + const songsCache = SongDetailsCacheService.getInstance(); + + const res = (args ?? []).reduce((acc, hash) => { + const songDetails = songsCache.getSongDetails(hash); + + if(songDetails){ + acc.push(songDetails); + } + + return acc; + }, [] as SongDetails[]); + + reply(of(res)); + +}) diff --git a/src/main/services/additional-content/maps/song-details-cache.service.ts b/src/main/services/additional-content/maps/song-details-cache.service.ts index 4a8d584e0..bd04bd4d1 100644 --- a/src/main/services/additional-content/maps/song-details-cache.service.ts +++ b/src/main/services/additional-content/maps/song-details-cache.service.ts @@ -81,7 +81,8 @@ export class SongDetailsCacheService { RawSongDetailsDeserializer.setDifficultyLabels(messageObj.difficultyLabels); for(const rawSong of messageObj.songs){ - res[rawSong.hash.toLocaleLowerCase()] = RawSongDetailsDeserializer.deserialize(rawSong); + const deserialized = RawSongDetailsDeserializer.deserialize(rawSong); + res[deserialized.hash.toLocaleLowerCase()] = deserialized; } return res; diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 4be7629fd..cf3863736 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -108,6 +108,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { } return [ + { icon: "add", text: "Créer une playlist", onClick: () => playlistsRef?.current?.createPlaylist?.() }, { icon: "sync", text: "Synchroniser les playlists", onClick: () => playlistsRef?.current?.syncPlaylists?.() }, { icon: "export", text: "Exporter les playlists", onClick: () => playlistsRef?.current?.exportPlaylists?.() }, { icon: "trash", text: "Supprimer les playlists", onClick: () => playlistsRef?.current?.deletePlaylists?.() }, diff --git a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx index c091eef03..bcd669aca 100644 --- a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx @@ -1,4 +1,4 @@ -import { MapFilter, MapRequirement, MapSpecificity, MapStyle, MapTag, MapType } from "shared/models/maps/beat-saver.model"; +import { BsvMapDetail, MapFilter, MapRequirement, MapSpecificity, MapStyle, MapTag, MapType } from "shared/models/maps/beat-saver.model"; import { motion } from "framer-motion"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { BsmCheckbox } from "../../shared/bsm-checkbox.component"; @@ -11,6 +11,8 @@ import { BsmButton } from "../../shared/bsm-button.component"; import equal from "fast-deep-equal/es6"; import clone from "rfdc"; import { GlowEffect } from "../../shared/glow-effect.component"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; +import { SongDetails } from "shared/models/maps"; export type Props = { className?: string; @@ -158,7 +160,7 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat }; return !playlist ? ( - +
                  @@ -220,3 +222,159 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat <> ); } + +// Filter functions + +function isFitEnabledTags(filter: MapFilter, tags: MapTag[]): boolean { + if(!Array.isArray(tags)) { return false; } + if (!filter?.enabledTags || filter.enabledTags.size === 0) { + return true; + } + return Array.from(filter.enabledTags.values()).every(tag => tags.some(mapTag => mapTag === tag)); +} + +function isFitExcludedTags(filter: MapFilter, tags: MapTag[]): boolean { + if(!Array.isArray(tags)) { return false; } + if (!filter?.excludedTags || filter.excludedTags.size === 0) { + return true; + } + return !tags.some(tag => filter.excludedTags.has(tag as MapTag)); +} + +function isFitMinNps(filter: MapFilter, nps: number): boolean { + if (!filter?.minNps) { return true; } + return nps > filter.minNps; +} + +function isFitMaxNps(filter: MapFilter, nps: number): boolean { + if (!filter?.maxNps) { return true; } + return nps < filter.maxNps; +} + +function isFitMinDuration(filter: MapFilter, duration: number): boolean { + if (!filter?.minDuration) { return true; } + return duration >= filter.minDuration; +} + +function isFitMaxDuration(filter: MapFilter, duration: number): boolean { + if (!filter?.maxDuration) { return true; } + return duration <= filter.maxDuration; +} + +function isFitNoodle(filter: MapFilter, noodle: boolean): boolean { + if (!filter?.noodle) { return true; } + return noodle; +} + +function isFitMe(filter: MapFilter, me: boolean): boolean { + if (!filter?.me) { return true; } + return me; +} + +function isFitCinema(filter: MapFilter, cinema: boolean): boolean { + if (!filter?.cinema) { return true; } + return cinema; +} + +function isFitChroma(filter: MapFilter, chroma: boolean): boolean { + if (!filter?.chroma) { return true; } + return chroma; +} + +function isFitFullSpread(filter: MapFilter, nbDiff: number): boolean { + if (!filter?.fullSpread) { return true; } + return nbDiff >= 5; +} + +function isFitAutomapper(filter: MapFilter, automapper: boolean): boolean { + if (!filter?.automapper) { return true; } + return automapper; +} + + +function isFitRanked(filter: MapFilter, ranked: boolean): boolean { + if (!filter?.ranked) { return true; } + return ranked; +} + +function isFitCurated(filter: MapFilter, curated: boolean): boolean { + if (!filter?.curated) { return true; } + return curated; +} + +function isFitVerified(filter: MapFilter, verified: boolean): boolean { + if (!filter?.verified) { return true; } + return verified; +} + +function isFitSearch(search: string, {songName, songAuthorName, levelAuthorName}: {songName: string, songAuthorName: string, levelAuthorName: string}): boolean { + if (!search) { return true; } + return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase()) || levelAuthorName?.toLowerCase().includes(search.toLowerCase()); +} + +export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap, search: string }): boolean => { + if (!isFitEnabledTags(filter, map.songDetails?.tags)) { return false; } + if (!isFitExcludedTags(filter, map.songDetails?.tags)) { return false; } + if (map?.songDetails?.difficulties?.length && !map.songDetails?.difficulties.some(diff => isFitMinNps(filter, diff.nps))) { return false; } + if (map?.songDetails?.difficulties?.length && !map.songDetails?.difficulties.some(diff => isFitMaxNps(filter, diff.nps))) { return false; } + if (!isFitMinDuration(filter, map.songDetails?.duration)) { return false; } + if (!isFitMaxDuration(filter, map.songDetails?.duration)) { return false; } + if (!isFitNoodle(filter, map.songDetails?.difficulties.some(diff => !!diff.ne))) { return false; } + if (!isFitMe(filter, map.songDetails?.difficulties.some(diff => !!diff.me))) { return false; } + if (!isFitCinema(filter, map.songDetails?.difficulties.some(diff => !!diff.cinema))) { return false; } + if (!isFitChroma(filter, map.songDetails?.difficulties.some(diff => !!diff.chroma))) { return false; } + if (!isFitFullSpread(filter, map.songDetails?.difficulties.length)) { return false; } + if (!isFitAutomapper(filter, map.songDetails?.automapper)){ return false; } + if (!isFitRanked(filter, map.songDetails?.ranked)) { return false; } + if (!isFitCurated(filter, map.songDetails?.curated)) { return false; } + if (!isFitVerified(filter, map.songDetails?.uploader.verified)) { return false; } + if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; } + return true; +}; + +export const isBsvMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsvMapDetail, search: string }): boolean => { + if (!isFitEnabledTags(filter, map.tags)) { return false; } + if (!isFitExcludedTags(filter, map.tags)) { return false; } + if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { return false; } + if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { return false; } + if (!isFitMinDuration(filter, map.metadata.duration)) { return false; } + if (!isFitMaxDuration(filter, map.metadata.duration)) { return false; } + if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { return false; } + if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { return false; } + if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { return false; } + if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { return false; } + if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { return false; } + if (!isFitAutomapper(filter, map.automapper)){ return false; } + if (!isFitRanked(filter, map.ranked)) { return false; } + if (!isFitCurated(filter, !!map.curator)) { return false; } + if (!isFitVerified(filter, !!map.curatedAt)) { return false; } + if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; } + return true; +}; + +export const isSongDetailsFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: SongDetails, search: string }): boolean => { + if (!isFitEnabledTags(filter, map.tags)) { return false; } + if (!isFitExcludedTags(filter, map.tags)) { return false; } + if (map.difficulties && !map.difficulties.some(diff => isFitMinNps(filter, diff.nps))) { return false; } + if (map.difficulties && !map.difficulties.some(diff => isFitMaxNps(filter, diff.nps))) { return false; } + if (!isFitMinDuration(filter, map.duration)) { return false; } + if (!isFitMaxDuration(filter, map.duration)) { return false; } + if (!isFitNoodle(filter, map.difficulties.some(diff => !!diff.ne))) { return false; } + if (!isFitMe(filter, map.difficulties.some(diff => !!diff.me))) { return false; } + if (!isFitCinema(filter, map.difficulties.some(diff => !!diff.cinema))) { return false; } + if (!isFitChroma(filter, map.difficulties.some(diff => !!diff.chroma))) { return false; } + if (!isFitFullSpread(filter, map.difficulties.length)) { return false; } + if (!isFitAutomapper(filter, map.automapper)){ return false; } + if (!isFitRanked(filter, map.ranked)) { return false; } + if (!isFitCurated(filter, map.curated)) { return false; } + if (!isFitVerified(filter, map.uploader.verified)) { return false; } + if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: map.uploader.name})) { return false; } + return true; +} + +export const isMapFitFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap | BsvMapDetail | SongDetails, search: string }): boolean => { + if ((map as BsmLocalMap)?.rawInfo) { return isLocalMapFitMapFilter({filter, map: (map as BsmLocalMap), search}); } + if ((map as BsvMapDetail)?.metadata) { return isBsvMapFitMapFilter({filter, map: (map as BsvMapDetail), search}); } + if ((map as SongDetails).hash) { return isSongDetailsFitMapFilter({filter, map: (map as SongDetails), search}); } + return false; +}; diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 1d04f0763..ce2dbc535 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -21,6 +21,7 @@ import { useObservable } from "renderer/hooks/use-observable.hook"; import equal from "fast-deep-equal"; import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; import { MapItem, extractMapDiffs } from "./map-item.component"; +import { isLocalMapFitMapFilter } from "./filter-panel.component"; type Props = { version: BSVersion; @@ -175,178 +176,10 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa callBackParam={map} /> ); - }, [version]) - - const isMapFitFilter = (map: BsmLocalMap): boolean => { - // Can be more clean and optimized i think - - const fitEnabledTags = (() => { - - if (!filter?.enabledTags || filter.enabledTags.size === 0) { - return true; - } - if (!map?.songDetails?.tags) { - return false; - } - return Array.from(filter.enabledTags.values()).every(tag => map.songDetails.tags.some(mapTag => mapTag === tag)); - })(); - - if (!fitEnabledTags) { - return false; - } - - const fitExcluedTags = (() => { - if (!filter?.excludedTags || filter.excludedTags.size === 0) { - return true; - } - if (!map?.songDetails?.tags) { - return true; - } - return !map.songDetails?.tags.some(tag => filter.excludedTags.has(tag as MapTag)); - })(); - - if (!fitExcluedTags) { - return false; - } - - const fitMinNps = (() => { - if (!filter?.minNps) { - return true; - } - - return map.songDetails?.difficulties.some(diff => diff.nps > filter.minNps); - })(); - - if (!fitMinNps) { - return false; - } - - const fitMaxNps = (() => { - if (!filter?.maxNps) { - return true; - } - - return map.songDetails?.difficulties.some(diff => diff.nps < filter.maxNps); - })(); - - if (!fitMaxNps) { - return false; - } - - const fitMinDuration = (() => { - if (!filter?.minDuration) { - return true; - } - - if (!map?.songDetails?.duration) { - return false; - } - return map.songDetails?.duration >= filter.minDuration; - })(); - - if (!fitMinDuration) { - return false; - } - - const fitMaxDuration = (() => { - if (!filter?.maxDuration) { - return true; - } - if (!map?.songDetails?.duration) { - return false; - } - return map.songDetails?.duration <= filter.maxDuration; - })(); - - if (!fitMaxDuration) { - return false; - } - - const fitNoodle = (() => { - if (!filter?.noodle) { - return true; - } - return map.songDetails?.difficulties.some(diff => !!diff.ne); - })(); - - if (!fitNoodle) { - return false; - } - - const fitMe = (() => { - if (!filter?.me) { - return true; - } - - return map.songDetails?.difficulties.some(diff => !!diff.me); - })(); - - if (!fitMe) { - return false; - } - - const fitCinema = (() => { - if (!filter?.cinema) { - return true; - } - - return map.songDetails?.difficulties.some(diff => !!diff.cinema); - })(); - - if (!fitCinema) { - return false; - } - - const fitChroma = (() => { - if (!filter?.chroma) { - return true; - } - - return map.songDetails?.difficulties.some(diff => !!diff.chroma); - })(); - - if (!fitChroma) { - return false; - } - - const fitFullSpread = (() => { - if (!filter?.fullSpread) { - return true; - } - - return map.songDetails?.difficulties.length >= 5; - })(); - - if (!fitFullSpread) { - return false; - } - - if (filter?.automapper && (map.songDetails && !map.songDetails?.automapper)) { - return false; - } - if (!(filter?.ranked ? map.songDetails?.ranked === filter.ranked : true)) { - return false; - } - if (!(filter?.curated ? !!map.songDetails?.curated === filter.curated : true)) { - return false; - } - if (!(filter?.verified ? !!map.songDetails?.uploader?.verified : true)) { - return false; - } - - const searchCheck = (() => { - return (map.rawInfo?._songName || "")?.toLowerCase().includes(search.toLowerCase()) || (map.rawInfo?._songAuthorName || "")?.toLowerCase().includes(search.toLowerCase()) || (map.rawInfo?._levelAuthorName || "")?.toLowerCase().includes(search.toLowerCase()); - })(); - - if (!searchCheck) { - return false; - } - - return true; - }; + }, [version]); const preppedMaps: RenderableMap[] = (() => { - return renderableMaps?.filter(renderableMap => isMapFitFilter(renderableMap.map)) ?? []; + return renderableMaps?.filter(renderableMap => isLocalMapFitMapFilter({ map: renderableMap.map, filter, search })) ?? []; })(); if (!maps) { diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index efa32a9ec..9cf0c5879 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -27,18 +27,18 @@ import { typedMemo } from "renderer/helpers/typed-memo"; export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number }; -export type MapItemProps = { +export type MapItemComponentProps = { hash: string; title: string; autor: string; - songAutor: string; - coverUrl: string; - songUrl: string; + songAutor?: string; + coverUrl?: string; + songUrl?: string; autorId: number; mapId: string; diffs: Map; ranked: boolean; - bpm: number; + bpm?: number; duration: number; likes: number; createdAt: number | CalendarDateTime; @@ -53,7 +53,7 @@ export type MapItemProps = { onDoubleClick?: (param: T) => void; }; -export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemProps) { +export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps) { const linkOpener = useService(LinkOpenerService); const audioPlayer = useService(AudioPlayerService); @@ -115,7 +115,7 @@ export function MapItemComponent ({ hash, title, autor, songAutor, if (!audioPlayer.playing && audioPlayer.src === songUrl) { return audioPlayer.resume(); } - audioPlayer.play([{ src: songUrl, bpm }]); + audioPlayer.play([{ src: songUrl, bpm: bpm ?? 1 }]); }; const bottomBarHoverStart = () => { diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx index db15466a0..952e837b1 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx @@ -18,8 +18,6 @@ const [MIN_NB_MAPPER, MAX_NB_MAPPER] = [0, 1000]; const [MIN_DURATION, MAX_DURATION] = [0, hourToS(9)]; const [MIN_NPS, MAX_NPS] = [0, 17]; -console.log(hourToS(9)); - export function LocalPlaylistFilterPanel({ className, filter, onChange }: Props) { const t = useTranslation(); diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index ed1e70aaf..4a3d4ccb6 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -37,6 +37,7 @@ import { ProgressionInterface } from "shared/models/progress-bar"; import { enumerate } from "shared/helpers/array.helpers"; import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component"; import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component"; +import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal.component"; type Props = { version: BSVersion; @@ -48,6 +49,7 @@ type Props = { }; export type LocalPlaylistsListRef = { + createPlaylist: () => Promise; syncPlaylists: () => Promise; deletePlaylists: () => Promise; exportPlaylists: () => Promise; @@ -87,6 +89,9 @@ export const LocalPlaylistsListPanel = forwardRef( } useImperativeHandle(forwardedRef, () => ({ + createPlaylist: async () => { + const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$ } }); + }, syncPlaylists: async () => { if(!isOnline){ return; } const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; @@ -270,7 +275,6 @@ export const LocalPlaylistsListPanel = forwardRef( isInQueue$={playlistDownloader.$isPlaylistInQueue(playlist.customData?.syncURL ?? playlist.path, version)} selected$={selectedPlaylists$.pipe(map(selected => selected.some(s => s.path === playlist.path)), distinctUntilChanged(equal))} onClick={() => { - console.log(selectedPlaylists$.value, playlist.path); if(selectedPlaylists$.value.some(s => s.path === playlist.path)){ selectedPlaylists$.next(selectedPlaylists$.value.filter(s => s.path !== playlist.path)); return; diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx index ee1cea4f9..56cee3d86 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx @@ -139,7 +139,6 @@ export const DownloadPlaylistModal: ModalComponent items.map(item => item.playlist.playlistId).join("-")} /> ) })()} diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx new file mode 100644 index 000000000..0b3487942 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx @@ -0,0 +1,184 @@ +import { useCallback, useMemo, useState } from "react"; +import { FilterPanel, isLocalMapFitMapFilter, isMapFitFilter } from "renderer/components/maps-playlists-panel/maps/filter-panel.component"; +import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component"; +import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component"; +import { BsmSelect } from "renderer/components/shared/bsm-select.component"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; +import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { ModalComponent } from "renderer/services/modale.service" +import { Observable, filter, lastValueFrom, take } from "rxjs"; +import { BSVersion } from "shared/bs-version.interface"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; +import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models" +import { BsvMapDetail, SongDetails } from "shared/models/maps"; +import { logRenderError } from "renderer"; +import { useService } from "renderer/hooks/use-service.hook"; +import { MapsManagerService } from "renderer/services/maps-manager.service"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { MapItemComponentPropsMapper } from "shared/mappers/map/map-item-component-props.mapper"; +import { MapFilter } from "shared/models/maps/beat-saver.model"; + +type Props = { + version?: BSVersion; + maps$: Observable; + playlist?: LocalBPList; +} + +export const EditPlaylistModal: ModalComponent = ({ resolver, options: { data: { version, maps$, playlist } } }) => { + + const t = useTranslation(); + const color = useThemeColor("first-color"); + + const mapsService = useService(MapsManagerService); + + const maps = useObservable(() => maps$.pipe(filter(Array.isArray), take(1)), undefined); + + const [availableMapsSearch, setAvailableMapsSearch] = useState(""); + const [availableMapsFilter, setAvailableMapsFilter] = useState({}); + const [playlistMapsSearch, setPlaylistMapsSearch] = useState(""); + const [playlistMapsFilter, setPlaylistMapsFilter] = useState({}); + + const [availableMapsSource, setAvailableMapsSource] = useState(0); + + const [playlistMaps, setPlaylistMaps] = useState>(); + const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]); + + const availableMaps = (() => { + + })(); + + useOnUpdate(() => { + if(!maps){ return; } + + const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => { + const map = maps.find(map => map.hash === song.hash); + + if(map){ + acc[map.hash] = map; + } + else { + acc[song.hash] = undefined; + } + + return acc; + }, {} as Record); + + + (async () => { + const notInstalledHashs = Object.keys(playlistMapsRes).filter(hash => !playlistMapsRes[hash]); + const songsDetails = await lastValueFrom(mapsService.getMapsInfoFromHashs(notInstalledHashs)); + + songsDetails.forEach(song => { + playlistMapsRes[song.hash] = song; + }); + + })() + .catch(logRenderError) + .finally(() => setPlaylistMaps(playlistMapsRes)); + }, [maps]); + + useOnUpdate(() => { + + }, [maps, playlistMaps, playlist]); + + const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void) => { + + return ( + + ); + }, []); + + const renderAvailableMapItem = useCallback((map: BsmLocalMap) => { + return renderMapItem(map, () => setPlaylistMaps(prev => ({ ...prev, [map.hash]: map }))); + }, []); + + const renderPlaylistMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => { + return renderMapItem(map, () => setPlaylistMaps(prev => { + + if(!map){ return prev; } + + const hash = (map as BsmLocalMap | SongDetails).hash ? (map as BsmLocalMap|SongDetails).hash : (map as BsvMapDetail).versions?.[0]?.hash; + + if(!hash){ return prev; } + + const newPlaylistMaps = { ...prev }; + delete newPlaylistMaps[(map as BsmLocalMap).hash]; + return newPlaylistMaps; + })); + }, []); + + const renderList = (maps: T[], render: (item: T) => JSX.Element) => { + return ( + + ) + } + + return ( +
                  + {(() => { + if(!playlistMaps || !maps){ + return
                  Loading...
                  + } + else{ + return ( +
                  +
                  header
                  +
                  +
                  +
                  + + setAvailableMapsSearch(() => e.target.value)} /> + + + +
                  + {renderList(maps.filter(map => { + if(playlistMaps[map.hash]){ return false; } + return isLocalMapFitMapFilter({ map, filter: availableMapsFilter, search: availableMapsSearch }); + }), renderAvailableMapItem)} +
                  +
                  +
                  +
                  + + +
                  +
                  +
                  + setPlaylistMapsSearch(() => e.target.value)} /> + + + +
                  + {renderList(displayablePlaylistMaps.filter(map => { + return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch }); + }), renderPlaylistMapItem)} +
                  + {Object.keys(playlistMaps ?? {}).length} Maps +
                  +
                  +
                  +
                  footer
                  +
                  + ) + } + })()} +
                  + ) +} diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx index 9f08114a1..4b1dd0f8b 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx @@ -84,15 +84,8 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver }, []); const renderMaps = () => { - if (!Array.isArray(installedMaps) && !isInQueue) { - return ( -
                  - -
                  - ); - } - if(installedMaps.length === 0 && !isInQueue) { + if(!installedMaps.length && !isInQueue) { return (
                  diff --git a/src/renderer/components/shared/bsm-dropdown-button.component.tsx b/src/renderer/components/shared/bsm-dropdown-button.component.tsx index 09cc55e0f..d6da64153 100644 --- a/src/renderer/components/shared/bsm-dropdown-button.component.tsx +++ b/src/renderer/components/shared/bsm-dropdown-button.component.tsx @@ -62,10 +62,12 @@ export const BsmDropdownButton = forwardRef(({ className, items, align, withBar return "right-0 origin-top-right"; })(); + console.log("alignClass", alignClass); + return (
                  } className={className}> setExpanded(!expanded)} className={buttonClassName ?? defaultButtonClassName} icon={icon} active={expanded} textClassName={textClassName} onClickOutside={handleClickOutside} withBar={withBar} text={text} /> -
                  +
                  {items?.map( i => i && ( diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index ce9c60cfc..adcfb62ba 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -5,10 +5,10 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { VirtualRow } from "./virtual-row.component"; import { splitIntoChunk } from "shared/helpers/array.helpers"; import { useConstant } from "renderer/hooks/use-constant.hook"; -import { BehaviorSubject, debounceTime } from "rxjs"; +import { BehaviorSubject, debounceTime, distinctUntilChanged } from "rxjs"; import { useObservable } from "renderer/hooks/use-observable.hook"; -type ClassNames = { +export type VirtualScrollClassNames = { mainDiv?: string; variableList?: string; rows?: string; @@ -21,8 +21,8 @@ type ScrollEndHandler = { type Props = { className?: string; - classNames?: ClassNames; - minItemWidth: number; + classNames?: VirtualScrollClassNames; + minItemWidth?: number; maxColumns: number; minColumns?: number; itemHeight: number; @@ -40,13 +40,15 @@ export function VirtualScroll({ className, classNames, minItemWidth const [itemPerRow, setItemPerRow] = useState(1); const [itemsToRender, setItemsToRender] = useState([]); const listHeight$ = useConstant(() => new BehaviorSubject(0)); - const listHeight = useObservable(() => listHeight$.pipe(debounceTime(100)), 0); + const listHeight = useObservable(() => listHeight$.pipe(distinctUntilChanged(), debounceTime(100)), 0); + + console.log(listHeight); useLayoutEffect(() => { const updateItemPerRow = (listWidth: number) => { if (!listWidth) return; - const calculatedColumns = Math.floor(listWidth / minItemWidth); + const calculatedColumns = Math.floor(listWidth / (minItemWidth ?? 1)); const newColumns = Math.max((minColumns || 1), Math.min(maxColumns, calculatedColumns)); setItemPerRow(() => newColumns); }; @@ -64,6 +66,7 @@ export function VirtualScroll({ className, classNames, minItemWidth useOnUpdate(() => { const splitedItems = splitIntoChunk(items, itemPerRow); setItemsToRender(() => splitedItems); + }, [itemPerRow, items]) const handleScroll = (e: ListOnScrollProps) => { diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 42a955433..58095d668 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -12,6 +12,7 @@ import { ConfigurationService } from "./configuration.service"; import { map, last, catchError } from "rxjs/operators"; import { ProgressionInterface } from "shared/models/progress-bar"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; +import { SongDetails } from "shared/models/maps"; export class MapsManagerService { private static instance: MapsManagerService; @@ -145,6 +146,10 @@ export class MapsManagerService { }); } + public getMapsInfoFromHashs(hashs: string[]): Observable { + return this.ipcService.sendV2("get-maps-info-from-cache", hashs); + } + public async isDeepLinksEnabled(): Promise { return lastValueFrom(this.ipcService.sendV2("is-map-deep-links-enabled")); } diff --git a/src/shared/mappers/map/map-item-component-props.mapper.ts b/src/shared/mappers/map/map-item-component-props.mapper.ts new file mode 100644 index 000000000..ba5098d08 --- /dev/null +++ b/src/shared/mappers/map/map-item-component-props.mapper.ts @@ -0,0 +1,106 @@ +import { getLocalTimeZone, parseAbsolute, parseDateTime, toCalendarDateTime } from "@internationalized/date"; +import { MapItemComponentProps, ParsedMapDiff } from "renderer/components/maps-playlists-panel/maps/map-item.component"; +import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetails } from "shared/models/maps"; +import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; + +export abstract class MapItemComponentPropsMapper { + + public static extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map { + const res = new Map(); + if (bsvMap && bsvMap.versions?.at(0)?.diffs) { + bsvMap.versions.at(0).diffs.forEach(diff => { + const arr = res.get(diff.characteristic) || []; + arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars }); + res.set(diff.characteristic, arr); + }); + return res; + } + + if (songDetails?.difficulties) { + songDetails?.difficulties.forEach(diff => { + const arr = res.get(diff.characteristic) || []; + const diffName = rawMapInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty; + arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars }); + res.set(diff.characteristic, arr); + }); + return res; + } + + rawMapInfo._difficultyBeatmapSets.forEach(set => { + set._difficultyBeatmaps.forEach(diff => { + const arr = res.get(set._beatmapCharacteristicName) || []; + arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null }); + res.set(set._beatmapCharacteristicName, arr); + }); + }); + + return res; + } + + public static fromBsmLocalMap(map: BsmLocalMap): MapItemComponentProps { + return { + hash: map.hash, + title: map.rawInfo._songName, + coverUrl: map.coverUrl, + songUrl: map.songUrl, + autor: map.rawInfo._levelAuthorName, + songAutor: map.rawInfo._songAuthorName, + bpm: map.rawInfo._beatsPerMinute, + duration: map.songDetails?.duration, + diffs: MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails }), + mapId: map.songDetails?.id, + ranked: map.songDetails?.ranked, + autorId: map.songDetails?.uploader.id, + likes: map.songDetails?.upVotes, + createdAt: map.songDetails?.uploadedAt, + callBackParam: map + } + } + + public static fromBsvMapDetail(map: BsvMapDetail): MapItemComponentProps { + return { + autor: map.metadata.levelAuthorName, + autorId: map.uploader.id, + bpm: map.metadata.bpm, + coverUrl: map.versions.at(0).coverURL, + createdAt: map.createdAt && toCalendarDateTime(parseAbsolute(map.createdAt, getLocalTimeZone())), + duration: map.metadata.duration, + hash: map.versions.at(0).hash, + likes: map.stats.upvotes, + mapId: map.id, + ranked: map.ranked, + title: map.name, + songAutor: map.metadata.songAuthorName, + diffs: MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map }), + songUrl: map.versions.at(0).previewURL, + callBackParam: map + } + } + + public static fromSongDetails(song: SongDetails): MapItemComponentProps { + return { + autor: song.uploader.name, + autorId: song.uploader.id, + createdAt: song.uploadedAt, + duration: song.duration, + hash: song.hash, + likes: song.upVotes, + mapId: song.id, + ranked: song.ranked, + title: song.name, + diffs: MapItemComponentPropsMapper.extractMapDiffs({ songDetails: song }), + callBackParam: song + } + } + + public static from(mapDetails: BsmLocalMap|BsvMapDetail|SongDetails): MapItemComponentProps { + if ((mapDetails as BsmLocalMap).rawInfo) { + return MapItemComponentPropsMapper.fromBsmLocalMap(mapDetails as BsmLocalMap) as MapItemComponentProps; + } else if ((mapDetails as BsvMapDetail).metadata) { + return MapItemComponentPropsMapper.fromBsvMapDetail(mapDetails as BsvMapDetail) as MapItemComponentProps;; + } else { + return MapItemComponentPropsMapper.fromSongDetails(mapDetails as SongDetails) as MapItemComponentProps;; + } + } + +} diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 984499ad5..d6f8e55b5 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -2,7 +2,7 @@ import { Progression } from "main/helpers/fs.helpers"; import { Observable } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { BSLaunchEventData, LaunchOption } from "shared/models/bs-launch"; -import { BsvMapDetail } from "shared/models/maps"; +import { BsvMapDetail, SongDetails } from "shared/models/maps"; import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface"; import { BsvPlaylist, BsvPlaylistPage, PlaylistSearchParams, SearchParams } from "../maps/beat-saver.model"; import { ImportVersionOptions } from "main/services/bs-local-version.service"; @@ -58,6 +58,7 @@ export interface IpcChannelMapping { "register-maps-deep-link": { request: void, response: boolean }; "unregister-maps-deep-link": { request: void, response: boolean }; "is-map-deep-links-enabled": { request: void, response: boolean }; + "get-maps-info-from-cache": { request: string[], response: SongDetails[] } /* ** bs-model-ipcs ** */ "one-click-install-model": { request: MSModel, response: void }; diff --git a/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts b/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts index 2748fb883..0b77a268e 100644 --- a/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts +++ b/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts @@ -16,7 +16,8 @@ export interface UploadersList { // SongDetails Message export interface RawSongDetails { idInt: number; - hash: string; + hashIndices: number[]; + name: string; duration: number; uploaderRef: UploaderRef; uploadedAt: number; diff --git a/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts b/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts index 5bd1263e4..8fa8fb3ec 100644 --- a/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts +++ b/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts @@ -7,6 +7,8 @@ export abstract class RawSongDetailsDeserializer { public static uploaderList: UploadersList = { names: [], ids: [] }; public static difficultyLabels: string[] = []; + private static readonly HASH_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; + private constructor() {} public static setUploadersList(uploadersList: UploadersList): void { @@ -135,6 +137,10 @@ export abstract class RawSongDetailsDeserializer { return rawMapId.toString(16); } + private static deserializeHashIndices(hashIndices: number[]): string { + return hashIndices.map(index => this.HASH_CHARS[index]).join(""); + } + /** * Deserialize the raw song details to the song details model. * @param {RawSongDetails} rawSongDetails the raw song details to deserialize (normally get from the proto message) @@ -148,7 +154,8 @@ export abstract class RawSongDetailsDeserializer { return { id: this.deserializeMapId(rawSongDetails.idInt), - hash: rawSongDetails.hash, + hash: this.deserializeHashIndices(rawSongDetails.hashIndices), + name: rawSongDetails.name, duration: rawSongDetails.duration, uploader: this.deserializeRawUploader(rawSongDetails.uploaderRef), uploadedAt: rawSongDetails.uploadedAt, diff --git a/src/shared/models/maps/song-details-cache/song-details-cache.model.ts b/src/shared/models/maps/song-details-cache/song-details-cache.model.ts index 5dd77c412..e5ae12adc 100644 --- a/src/shared/models/maps/song-details-cache/song-details-cache.model.ts +++ b/src/shared/models/maps/song-details-cache/song-details-cache.model.ts @@ -3,6 +3,7 @@ import { MapTag } from "../beat-saver.model"; export interface SongDetails { id: string; hash: string; + name: string; duration: number; uploader: SongUploader; uploadedAt: number; From 5e1a021c3a9dade659fdc1dc10f1de2018b8b857 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Sun, 16 Jun 2024 21:13:54 +0200 Subject: [PATCH 26/36] [feature-107] progress on the modal for creating and editing playlists --- .../maps/filter-panel.component.tsx | 45 ++- .../maps/map-item.component.tsx | 19 +- .../edit-playlist-modal.component.tsx | 304 ++++++++++++++---- .../virtual-scroll.component.tsx | 4 +- src/shared/helpers/type.helpers.ts | 1 + .../map/map-item-component-props.mapper.ts | 3 + src/shared/models/maps/beat-saver.model.ts | 12 + 7 files changed, 292 insertions(+), 96 deletions(-) diff --git a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx index bcd669aca..d2fbf93bb 100644 --- a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx @@ -226,18 +226,14 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat // Filter functions function isFitEnabledTags(filter: MapFilter, tags: MapTag[]): boolean { + if (!filter?.enabledTags || filter.enabledTags.size === 0) { return true; } if(!Array.isArray(tags)) { return false; } - if (!filter?.enabledTags || filter.enabledTags.size === 0) { - return true; - } return Array.from(filter.enabledTags.values()).every(tag => tags.some(mapTag => mapTag === tag)); } function isFitExcludedTags(filter: MapFilter, tags: MapTag[]): boolean { + if (!filter?.excludedTags || filter.excludedTags.size === 0) { return true; } if(!Array.isArray(tags)) { return false; } - if (!filter?.excludedTags || filter.excludedTags.size === 0) { - return true; - } return !tags.some(tag => filter.excludedTags.has(tag as MapTag)); } @@ -325,7 +321,7 @@ export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilte if (!isFitChroma(filter, map.songDetails?.difficulties.some(diff => !!diff.chroma))) { return false; } if (!isFitFullSpread(filter, map.songDetails?.difficulties.length)) { return false; } if (!isFitAutomapper(filter, map.songDetails?.automapper)){ return false; } - if (!isFitRanked(filter, map.songDetails?.ranked)) { return false; } + if (!isFitRanked(filter, map.songDetails?.ranked || map.songDetails?.rankedBL)) { return false; } if (!isFitCurated(filter, map.songDetails?.curated)) { return false; } if (!isFitVerified(filter, map.songDetails?.uploader.verified)) { return false; } if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; } @@ -333,22 +329,25 @@ export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilte }; export const isBsvMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsvMapDetail, search: string }): boolean => { - if (!isFitEnabledTags(filter, map.tags)) { return false; } + + console.log("LAAAAAA", map); + + if (!isFitEnabledTags(filter, map.tags)) { console.log(1); return false; } if (!isFitExcludedTags(filter, map.tags)) { return false; } - if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { return false; } - if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { return false; } - if (!isFitMinDuration(filter, map.metadata.duration)) { return false; } - if (!isFitMaxDuration(filter, map.metadata.duration)) { return false; } - if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { return false; } - if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { return false; } - if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { return false; } - if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { return false; } - if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { return false; } - if (!isFitAutomapper(filter, map.automapper)){ return false; } - if (!isFitRanked(filter, map.ranked)) { return false; } - if (!isFitCurated(filter, !!map.curator)) { return false; } - if (!isFitVerified(filter, !!map.curatedAt)) { return false; } - if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; } + if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { console.log(2); return false; } + if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { console.log(3); return false; } + if (!isFitMinDuration(filter, map.metadata.duration)) { console.log(4); return false; } + if (!isFitMaxDuration(filter, map.metadata.duration)) { console.log(5); return false; } + if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { console.log(6); return false; } + if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { console.log(7); return false; } + if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { console.log(8); return false; } + if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { console.log(9); return false; } + if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { console.log(10); return false; } + if (!isFitAutomapper(filter, map.automapper)){ console.log(11); return false; } + if (!isFitRanked(filter, map.ranked || map.blRanked)) { console.log(12); return false; } + if (!isFitCurated(filter, !!map.curator)) { console.log(13); return false; } + if (!isFitVerified(filter, !!map.curatedAt)) { console.log(14); return false; } + if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { console.log(15); return false; } return true; }; @@ -365,7 +364,7 @@ export const isSongDetailsFitMapFilter = ({filter, map, search}: { filter: MapFi if (!isFitChroma(filter, map.difficulties.some(diff => !!diff.chroma))) { return false; } if (!isFitFullSpread(filter, map.difficulties.length)) { return false; } if (!isFitAutomapper(filter, map.automapper)){ return false; } - if (!isFitRanked(filter, map.ranked)) { return false; } + if (!isFitRanked(filter, map.ranked || map.rankedBL)) { return false; } if (!isFitCurated(filter, map.curated)) { return false; } if (!isFitVerified(filter, map.uploader.verified)) { return false; } if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: map.uploader.name})) { return false; } diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 9cf0c5879..135660bd3 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -24,6 +24,7 @@ import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetail import { useConstant } from "renderer/hooks/use-constant.hook"; import { CalendarDateTime, getLocalTimeZone } from "@internationalized/date"; import { typedMemo } from "renderer/helpers/typed-memo"; +import { Observable, of } from "rxjs"; export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number }; @@ -37,14 +38,17 @@ export type MapItemComponentProps = { autorId: number; mapId: string; diffs: Map; - ranked: boolean; + ranked?: boolean; + blRanked?: boolean; bpm?: number; duration: number; likes: number; createdAt: number | CalendarDateTime; selected?: boolean; + selected$?: Observable; downloading?: boolean; showOwned?: boolean; + isOwned$?: Observable; callBackParam: T; onDelete?: (param: T) => void; onDownload?: (param: T) => void; @@ -53,7 +57,7 @@ export type MapItemComponentProps = { onDoubleClick?: (param: T) => void; }; -export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps) { +export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, blRanked, bpm, duration, likes, createdAt, selected, selected$, downloading, showOwned, isOwned$, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps) { const linkOpener = useService(LinkOpenerService); const audioPlayer = useService(AudioPlayerService); @@ -61,6 +65,8 @@ export function MapItemComponent ({ hash, title, autor, songAutor, const t = useTranslation(); const ref = useRef(null); + const isSelected = useObservable(() => selected$ ?? of(selected), false, [selected$, selected]); + const isOwned = useObservable(() => isOwned$ ?? of(showOwned ?? false), false, [isOwned$, showOwned]); const [hovered, setHovered] = useState(false); const [bottomBarHovered, setBottomBarHovered, cancelBottomBarHovered] = useDelayedState(false); const [diffsPanelHovered, setDiffsPanelHovered] = useState(false); @@ -162,7 +168,7 @@ export function MapItemComponent ({ hash, title, autor, songAutor, return ( setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}> - + {(diffsPanelHovered || bottomBarHovered) && ( @@ -183,7 +189,7 @@ export function MapItemComponent ({ hash, title, autor, songAutor, )}
                  -
                  +
                  ({ hash, title, autor, songAutor, {t("maps.map-specificities.ranked")}
                  )} + {blRanked && ( +
                  + {t("maps.map-specificities.ranked")} +
                  + )}
                  {renderDiffPreview()}
                  diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx index 0b3487942..7ff195952 100644 --- a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx @@ -1,15 +1,15 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { FilterPanel, isLocalMapFitMapFilter, isMapFitFilter } from "renderer/components/maps-playlists-panel/maps/filter-panel.component"; import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component"; import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component"; -import { BsmSelect } from "renderer/components/shared/bsm-select.component"; -import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; +import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component"; +import { VirtualScroll, VirtualScrollEndHandler } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { ModalComponent } from "renderer/services/modale.service" -import { Observable, filter, lastValueFrom, take } from "rxjs"; +import { BehaviorSubject, Observable, Subject, filter, lastValueFrom, map, of, take } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models" @@ -19,7 +19,11 @@ import { useService } from "renderer/hooks/use-service.hook"; import { MapsManagerService } from "renderer/services/maps-manager.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { MapItemComponentPropsMapper } from "shared/mappers/map/map-item-component-props.mapper"; -import { MapFilter } from "shared/models/maps/beat-saver.model"; +import { BsvSearchOrder, MapFilter, SearchParams } from "shared/models/maps/beat-saver.model"; +import { useConstant } from "renderer/hooks/use-constant.hook"; +import Tippy from "@tippyjs/react"; +import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; type Props = { version?: BSVersion; @@ -33,41 +37,74 @@ export const EditPlaylistModal: ModalComponent = ({ const color = useThemeColor("first-color"); const mapsService = useService(MapsManagerService); + const beatSaver = useService(BeatSaverService); - const maps = useObservable(() => maps$.pipe(filter(Array.isArray), take(1)), undefined); + const keyPressed$ = useConstant(() => new BehaviorSubject(undefined)); + const filterContainerRef = useRef(null); + const localMaps$ = useConstant(() => new BehaviorSubject<(BsmLocalMap|SongDetails)[]>(undefined)); + const localMaps = useObservable(() => localMaps$, undefined); + + const availabledHashsSelected$ = useConstant(() => new BehaviorSubject([])); const [availableMapsSearch, setAvailableMapsSearch] = useState(""); const [availableMapsFilter, setAvailableMapsFilter] = useState({}); + + const [bsvLoading, setBsvLoading] = useState(false); + const bsvMaps$ = useConstant(() => new BehaviorSubject(undefined)); + const bsvMaps = useObservable(() => bsvMaps$, undefined); + const [bsvSearchOrder, setBsvSortOrder] = useState(BsvSearchOrder.Latest); + const [bsvSearchParams, setBsvSearchParams] = useState({ + sortOrder: bsvSearchOrder, + filter: availableMapsFilter, + page: 0, + q: availableMapsSearch, + }); + const sortOptions: BsmSelectOption[] = useConstant(() => { + return Object.values(BsvSearchOrder).map(sort => ({ text: `beat-saver.maps-sorts.${sort}`, value: sort })); + }); + + const playlistMaps$ = useConstant(() => new BehaviorSubject>(undefined)); + const playlistMaps = useObservable(() => playlistMaps$, undefined); + + const playlistHashsSelected$ = useConstant(() => new BehaviorSubject([])); const [playlistMapsSearch, setPlaylistMapsSearch] = useState(""); const [playlistMapsFilter, setPlaylistMapsFilter] = useState({}); const [availableMapsSource, setAvailableMapsSource] = useState(0); - const [playlistMaps, setPlaylistMaps] = useState>(); + const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]); - const availableMaps = (() => { + useOnUpdate(() => { + const keyDown = (e: KeyboardEvent) => keyPressed$.next(e.key); + document.addEventListener("keydown", keyDown); - })(); + const keyUp = () => keyPressed$.next(undefined); + document.addEventListener("keyup", keyUp); - useOnUpdate(() => { - if(!maps){ return; } + return () => { + document.removeEventListener("keydown", keyDown); + document.removeEventListener("keyup", keyUp); + } + }, []); - const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => { - const map = maps.find(map => map.hash === song.hash); + useOnUpdate(() => { + (async () => { + const maps = await lastValueFrom(maps$.pipe(take(1))); - if(map){ - acc[map.hash] = map; - } - else { - acc[song.hash] = undefined; - } + const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => { + const map = maps.find(map => map.hash === song.hash); - return acc; - }, {} as Record); + if(map){ + acc[map.hash] = map; + } + else { + acc[song.hash] = undefined; + } + return acc; + }, {} as Record); - (async () => { const notInstalledHashs = Object.keys(playlistMapsRes).filter(hash => !playlistMapsRes[hash]); const songsDetails = await lastValueFrom(mapsService.getMapsInfoFromHashs(notInstalledHashs)); @@ -75,64 +112,179 @@ export const EditPlaylistModal: ModalComponent = ({ playlistMapsRes[song.hash] = song; }); + localMaps$.next([...(maps ?? []), ...(songsDetails ?? [])]); + playlistMaps$.next(playlistMapsRes ?? {}); })() - .catch(logRenderError) - .finally(() => setPlaylistMaps(playlistMapsRes)); - }, [maps]); + .catch(logRenderError); + }, []); + + useOnUpdate(() => { + availabledHashsSelected$.next([]); + setAvailableMapsFilter({}); + setAvailableMapsSearch(""); + bsvMaps$.next([]); + setBsvSearchParams({ sortOrder: bsvSearchOrder, filter: availableMapsFilter, page: 0, q: availableMapsSearch }); + }, [availableMapsSource]) useOnUpdate(() => { + if(availableMapsSource === 0){ return; } + + setBsvLoading(true); + + (async () => { + const maps = await beatSaver.searchMaps(bsvSearchParams); + bsvMaps$.next([...(bsvMaps ?? []), ...maps]); + setBsvLoading(false); + })() + .catch(logRenderError) + }, [bsvSearchParams]) + + const getHashOfMap = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => { + return (map as BsmLocalMap | SongDetails).hash ?? (map as BsvMapDetail).versions?.[0]?.hash; + }, []); + + const handleOnClickMap = useCallback(async ({ map, selectedHashs$, mapsSource$, noKeyPressedFallBack } : { + map: BsmLocalMap | BsvMapDetail | SongDetails; + mapsSource$: Observable<(BsmLocalMap|BsvMapDetail|SongDetails)[]>; + selectedHashs$: BehaviorSubject; + noKeyPressedFallBack?: () => void; + }) => { - }, [maps, playlistMaps, playlist]); + const keyPressed = keyPressed$.value; + const mapHash = getHashOfMap(map); - const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void) => { + if(keyPressed === "Control"){ + const selectedHashs = selectedHashs$.value; + const newSelectedHashs = selectedHashs.includes(mapHash) ? selectedHashs.filter(hash => hash !== mapHash) : [...selectedHashs, mapHash]; + return selectedHashs$.next(newSelectedHashs); + } + + const mapsSource = await lastValueFrom(mapsSource$.pipe(take(1))); + + if(keyPressed === "Shift" && Array.isArray(mapsSource)){ + const selectedHashs = selectedHashs$.value; + let lastSelectedMapIndex = mapsSource.findIndex(map => getHashOfMap(map) === selectedHashs.at(0)); + lastSelectedMapIndex = lastSelectedMapIndex === -1 ? 0 : lastSelectedMapIndex; + const currentMapIndex = mapsSource.findIndex(map => getHashOfMap(map) === mapHash); + const newSelectedHashs = mapsSource.slice(Math.min(lastSelectedMapIndex, currentMapIndex), Math.max(lastSelectedMapIndex, currentMapIndex) + 1).map(map => getHashOfMap(map)); + return selectedHashs$.next(newSelectedHashs); + } + + noKeyPressedFallBack?.(); + selectedHashs$.next([]); + }, []); + + const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void, isSelected$: Observable, isOwned$?: Observable) => { return ( ); }, []); - const renderAvailableMapItem = useCallback((map: BsmLocalMap) => { - return renderMapItem(map, () => setPlaylistMaps(prev => ({ ...prev, [map.hash]: map }))); + const renderAvailableMapItem = useCallback((localMap: BsmLocalMap | SongDetails | BsvMapDetail) => { + const mapHash = getHashOfMap(localMap); + const isSelected$ = availabledHashsSelected$.pipe(map(hashs => hashs.includes(mapHash))); + return renderMapItem(localMap, () => handleOnClickMap({ + map: localMap, + mapsSource$: localMaps$ as BehaviorSubject<(BsmLocalMap|BsvMapDetail|SongDetails)[]>, + selectedHashs$: availabledHashsSelected$, + noKeyPressedFallBack: () => playlistMaps$.next(Object.assign({[mapHash]: localMap}, playlistMaps$.value)) + }), isSelected$); + }, []); + + const renderBsvMapItem = useCallback((bsvMap: BsvMapDetail) => { + const mapHash = getHashOfMap(bsvMap); + const isSelected$ = availabledHashsSelected$.pipe(map(hashs => hashs.includes(mapHash))); + const isOwned$ = playlistMaps$.pipe(map(playlistMaps => !!playlistMaps[mapHash])); + return renderMapItem(bsvMap, () => handleOnClickMap({ + map: bsvMap, + mapsSource$: bsvMaps$, + selectedHashs$: availabledHashsSelected$, + noKeyPressedFallBack: () => playlistMaps$.next(Object.assign({[mapHash]: bsvMap}, playlistMaps$.value)) + }), isSelected$, isOwned$); + }, []); + + const renderPlaylistMapItem = useCallback((playlistMap: (BsmLocalMap|BsvMapDetail|SongDetails)) => { + const mapHash = getHashOfMap(playlistMap); + const isSelected$ = playlistHashsSelected$.pipe(map(hashs => hashs.includes(mapHash))); + return renderMapItem(playlistMap, () => handleOnClickMap({ + map: playlistMap, + mapsSource$: playlistMaps$.pipe(map(Object.values)), + selectedHashs$: playlistHashsSelected$, + noKeyPressedFallBack: () => playlistMaps$.next(Object.fromEntries(Object.entries(playlistMaps$.value).filter(([hash]) => hash !== mapHash))) + }), isSelected$); }, []); - const renderPlaylistMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => { - return renderMapItem(map, () => setPlaylistMaps(prev => { + const renderList = (maps: T[], render: (item: T) => JSX.Element, scrollEndHandler?: VirtualScrollEndHandler) => { + return ( +
                  + +
                  + ) + } + + const addMapsToPlaylist = useCallback(() => { + const tmpLocalMaps = availableMapsSource === 0 ? (localMaps$.value ?? []) : (bsvMaps$.value ?? []) + const mapsToAdd = availabledHashsSelected$.value?.length ? ( + availabledHashsSelected$.value.map(hash => tmpLocalMaps.find(map => getHashOfMap(map) === hash)).filter(Boolean) + ) : tmpLocalMaps; + + const newPlaylistMaps = {...playlistMaps$.value} ?? {}; + mapsToAdd.forEach(map => newPlaylistMaps[getHashOfMap(map)] = map); - if(!map){ return prev; } + playlistMaps$.next(newPlaylistMaps); + availabledHashsSelected$.next([]); + }, [availableMapsSource]); - const hash = (map as BsmLocalMap | SongDetails).hash ? (map as BsmLocalMap|SongDetails).hash : (map as BsvMapDetail).versions?.[0]?.hash; + const removeMapsFromPlaylist = useCallback(() => { + const hashsToRemove = playlistHashsSelected$.value?.length ? ( + playlistHashsSelected$.value + ) : Object.keys(playlistMaps$.value ?? {}).filter(hash => playlistMaps$.value[hash]); - if(!hash){ return prev; } + const newPlaylistMaps = {...playlistMaps$.value}; + hashsToRemove.forEach(hash => { delete newPlaylistMaps[hash]; }); - const newPlaylistMaps = { ...prev }; - delete newPlaylistMaps[(map as BsmLocalMap).hash]; - return newPlaylistMaps; - })); + playlistMaps$.next(newPlaylistMaps); + playlistHashsSelected$.next([]); }, []); - const renderList = (maps: T[], render: (item: T) => JSX.Element) => { - return ( - - ) + const handleSortChange = (newSort: BsvSearchOrder) => { + setBsvSortOrder(() => newSort); + bsvMaps$.next([]); + setBsvSearchParams(() => ({ ...bsvSearchParams, sortOrder: newSort })); + }; + + const loadMoreBsvMaps = () => { + if(bsvLoading){ return; } + setBsvSearchParams(prev => ({ ...prev, page: prev.page + 1 })); + }; + + const handleNewSearch = () => { + if(availableMapsSource === 0){ return; } + bsvMaps$.next([]); + setBsvSearchParams(() => ({ page: 0, filter: availableMapsFilter, q: availableMapsSearch, sortOrder: bsvSearchOrder})); } return (
                  {(() => { - if(!playlistMaps || !maps){ + if(!playlistMaps || !localMaps){ return
                  Loading...
                  } else{ @@ -140,29 +292,47 @@ export const EditPlaylistModal: ModalComponent = ({
                  header
                  -
                  -
                  - - setAvailableMapsSearch(() => e.target.value)} /> - - +
                  +
                  + + setAvailableMapsSearch(() => e.target.value)} /> + {availableMapsSource === 1 && ( + + )} + + filterContainerRef.current.close()}/> + {availableMapsSource === 1 && ( + + )}
                  - {renderList(maps.filter(map => { - if(playlistMaps[map.hash]){ return false; } - return isLocalMapFitMapFilter({ map, filter: availableMapsFilter, search: availableMapsSearch }); - }), renderAvailableMapItem)} -
                  -
                  + + {availableMapsSource === 0 ? ( + renderList(localMaps.filter(map => { + if(playlistMaps?.[map.hash]){ return false; } + return isMapFitFilter({ map, filter: availableMapsFilter, search: availableMapsSearch }); + }), renderAvailableMapItem) + ) : ( + renderList(bsvMaps, renderBsvMapItem, { onScrollEnd: loadMoreBsvMaps }) + )} +
                  - - + + + + + +
                  setPlaylistMapsSearch(() => e.target.value)} /> - +
                  diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index adcfb62ba..6cc905902 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -14,7 +14,7 @@ export type VirtualScrollClassNames = { rows?: string; } -type ScrollEndHandler = { +export type VirtualScrollEndHandler = { onScrollEnd: () => void; margin?: number; } @@ -29,7 +29,7 @@ type Props = { items: T[]; renderItem: (item: T) => JSX.Element; rowKey?: (rowItems: T[]) => Key; - scrollEnd?: ScrollEndHandler; + scrollEnd?: VirtualScrollEndHandler; } export function VirtualScroll({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, rowKey}: Props) { diff --git a/src/shared/helpers/type.helpers.ts b/src/shared/helpers/type.helpers.ts index 3df721b66..7ee39b710 100644 --- a/src/shared/helpers/type.helpers.ts +++ b/src/shared/helpers/type.helpers.ts @@ -1 +1,2 @@ export type FieldRequired = Partial & Required>; // All fields of T are optional except for K +export type ObjectValues = T[keyof T]; // All values of T diff --git a/src/shared/mappers/map/map-item-component-props.mapper.ts b/src/shared/mappers/map/map-item-component-props.mapper.ts index ba5098d08..bc64da46a 100644 --- a/src/shared/mappers/map/map-item-component-props.mapper.ts +++ b/src/shared/mappers/map/map-item-component-props.mapper.ts @@ -50,6 +50,7 @@ export abstract class MapItemComponentPropsMapper { diffs: MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails }), mapId: map.songDetails?.id, ranked: map.songDetails?.ranked, + blRanked: map.songDetails?.rankedBL, autorId: map.songDetails?.uploader.id, likes: map.songDetails?.upVotes, createdAt: map.songDetails?.uploadedAt, @@ -69,6 +70,7 @@ export abstract class MapItemComponentPropsMapper { likes: map.stats.upvotes, mapId: map.id, ranked: map.ranked, + blRanked: map.blRanked, title: map.name, songAutor: map.metadata.songAuthorName, diffs: MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map }), @@ -87,6 +89,7 @@ export abstract class MapItemComponentPropsMapper { likes: song.upVotes, mapId: song.id, ranked: song.ranked, + blRanked: song.rankedBL, title: song.name, diffs: MapItemComponentPropsMapper.extractMapDiffs({ songDetails: song }), callBackParam: song diff --git a/src/shared/models/maps/beat-saver.model.ts b/src/shared/models/maps/beat-saver.model.ts index d26adb304..4ab0b5e90 100644 --- a/src/shared/models/maps/beat-saver.model.ts +++ b/src/shared/models/maps/beat-saver.model.ts @@ -1,3 +1,4 @@ +import { ObjectValues } from "shared/helpers/type.helpers"; import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache/song-details-cache.model"; export interface BsvMapDetail { @@ -13,6 +14,9 @@ export interface BsvMapDetail { name: string; qualified: boolean; ranked: boolean; + blQualified: boolean + blRanked: boolean + declaredAi: ObjectValues; stats: BsvMapStats; tags: MapTag[]; updatedAt: BsvInstant; @@ -212,6 +216,14 @@ export enum MapSpecificity { } +//[ Admin, Uploader, SageScore, None ] +export const BsvDeclaredAi = { + Admin: "Admin", + Uploader: "Uploader", + SageScore: "SageScore", + None: "None" +} as const; + export interface MapFilter { automapper?: boolean; chroma?: boolean; From d299553d1eea81ca5d30843587ac209585ac2da2 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Tue, 18 Jun 2024 21:03:13 +0200 Subject: [PATCH 27/36] [feature-107] Advancement on playlist creation --- src/main/ipcs/os-controls-ipcs.ts | 13 ++ .../local-playlists-list-panel.component.tsx | 6 +- .../playlists/playlist-item.component.tsx | 4 +- .../login-to-steam-modal.component.tsx | 4 +- .../models/delete-models-modal.component.tsx | 2 +- .../edit-playlist-infos-modal.component.tsx | 80 +++++++++++ .../edit-playlist-modal.component.tsx | 130 +++++++++++++++--- .../components/shared/bsm-image.component.tsx | 1 + .../steam-downloader.service.ts | 2 +- src/shared/models/ipc/ipc-routes.ts | 1 + 10 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx rename src/renderer/components/modal/modal-types/playlist/{ => edit-playlist-modal}/edit-playlist-modal.component.tsx (72%) diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index 7e2e264a6..1de9cfe48 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -2,6 +2,7 @@ import { shell, dialog, app, BrowserWindow } from "electron"; import { NotificationService } from "../services/notification.service"; import { IpcService } from "../services/ipc.service"; import { from, of } from "rxjs"; +import { readFileSync } from "fs-extra"; // TODO IMPROVE WINDOW CONTROL BY USING WINDOW SERVICE @@ -45,3 +46,15 @@ ipc.on("notify-system", (args, reply) => { ipc.on("view-path-in-explorer", (args, reply) => { reply(of(shell.showItemInFolder(args))); }); + +ipc.on("choose-image", (args, reply) => { + reply(from(dialog.showOpenDialog({ properties: ["openFile", "multiSelections"], filters: [{ name: "Images", extensions: ["jpg", "png", "jpeg"] }] }).then(res => { + if (res.canceled || !res.filePaths) { + return []; + } + if(args.base64){ + return res.filePaths.map(path => Buffer.from(readFileSync(path)).toString("base64")); + } + return res.filePaths; + }))); +}); diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index 4a3d4ccb6..d10b1056a 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -6,7 +6,7 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; -import { BehaviorSubject, combineAll, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; +import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; @@ -37,7 +37,7 @@ import { ProgressionInterface } from "shared/models/progress-bar"; import { enumerate } from "shared/helpers/array.helpers"; import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component"; import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component"; -import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal.component"; +import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component"; type Props = { version: BSVersion; @@ -77,8 +77,6 @@ export const LocalPlaylistsListPanel = forwardRef( const playlists = useObservable(() => playlists$, []); - console.log(playlists); - const [playlistsLoading, setPlaylistsLoading] = useState(false); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index 01b66b57c..e97ebe135 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -75,9 +75,7 @@ export function PlaylistItem({ title, if (!duration) { return null; } - const date = new Date(0); - date.setSeconds(duration); - return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss"); + return duration > 3600 ? dateFormat(duration * 1000, "h:MM:ss") : dateFormat(duration * 1000, "MM:ss"); })(); // TODO : Translate diff --git a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx index 4fbef88f6..7900e4752 100644 --- a/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/bs-downgrade/login-to-steam-modal.component.tsx @@ -59,14 +59,14 @@ export const LoginToSteamModal: ModalComponent< - setUsername(e.target.value)} value={username} type="text" name="username" id="username" placeholder={t("modals.steam-login.inputs.username.placeholder")} /> + setUsername(e.target.value)} value={username} type="text" name="username" id="username" placeholder={t("modals.steam-login.inputs.username.placeholder")} />
                  - setPassword(e.target.value)} value={password} type={showPassword ? "text" : "password"} name="password" id="password" placeholder={t("modals.steam-login.inputs.password.placeholder")} /> + setPassword(e.target.value)} value={password} type={showPassword ? "text" : "password"} name="password" id="password" placeholder={t("modals.steam-login.inputs.password.placeholder")} /> setShowPassword(prev => !prev)} />
                  {password?.length > 64 && {t("modals.steam-login.inputs.password.max-length-warning")}} diff --git a/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx index 7adf355c0..acf57544d 100644 --- a/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/delete-models-modal.component.tsx @@ -24,7 +24,7 @@ export const DeleteModelsModal: ModalComponent 1; const title = useConstant(() => (isMultiple ? t("models.modals.delete-models.title") : t("models.modals.delete-model.title"))); - const desc = useConstant(() => (isMultiple ? t("models.modals.delete-models.desc", { nb: `${data.models.length}` }) : t("models.modals.delete-model.desc", { modelName: data.models[0].model?.name ?? data.models[0].fileName }))); + const desc = useConstant(() => (isMultiple ? t("models.modals.delete-models.desc", { nb: `${data.models.length}` }) : t("models.modals.delete-model.desc", { modelName: data.models[0]?.model?.name ?? data.models[0]?.fileName }))); const linkedAnnotation = useConstant(() => (() => { if (!data.linked) return null; diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx new file mode 100644 index 000000000..131c30211 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx @@ -0,0 +1,80 @@ +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { useState } from "react"; +import { useService } from "renderer/hooks/use-service.hook"; +import { SteamDownloaderService } from "renderer/services/bs-version-download/steam-downloader.service"; +import { IpcService } from "renderer/services/ipc.service"; +import { lastValueFrom } from "rxjs"; +import { logRenderError } from "renderer"; + +type Props = { + playlistTitle: string; + playlistDescription: string; + playlistAuthor: string; + base64Image: string; +} + +export const EditPlaylistInfosModal: ModalComponent = ({ resolver, options: { data: { + playlistTitle, + playlistDescription, + playlistAuthor, + base64Image +}}}) => { + + const steamDownloader = useService(SteamDownloaderService); + const ipc = useService(IpcService); + + const [title, setTitle] = useState(playlistTitle); + const [description, setDescription] = useState(playlistDescription); + const [author, setAuthor] = useState(playlistAuthor ?? steamDownloader.getSteamUsername()); + const [base64, setBase64] = useState(base64Image); + + const handleClickImage = async () => { + const res = await lastValueFrom(ipc.sendV2("choose-image", { base64: true })).catch(logRenderError) as string[]; + setBase64(prev => res?.at(0) ?? prev); + } + + const submit = () => { + resolver({ + exitCode: ModalExitCode.COMPLETED, + data: { + playlistTitle: title, + playlistDescription: description, + playlistAuthor: author, + base64Image: base64 + } + }); + }; + + return ( +
                  +

                  Créer une playlist

                  +
                  + +
                  + + setTitle(e.target.value)}/> +
                  +
                  + +