{video.views} · 2 hari lalu
@^>}r=vkx!C+~05P967r!k0|_%FrI$7}_5$*xt6MH1;g}HCeC>E PHb6E0xS za%-5#tXEN?acH;y6S%q0YcKwAS|!YhAOrWv2;cC$4M&T(R~}Z#^xH(N*I%mlXk@F6 zi;t*~bfYHrIj-NJ5$zb?uvz&BwFtbF<(Z2M*y~ibCc#x% MYmA9^PoewX58{4|R4f+u6=KR<{x#9AFZ z=dqea1E?D!ivgthuUpKekRjVN+^~e2I?ES31BQR$O!9p@+WgE2S9eSkfs0DodC~V1 ze4moBtZd=$6|AP*V~Ikp??%t?dx$}a0KUZ8$Qe68bi4YAji#Wqm>BxI&-{C`lo>*F zV20vam$;-#A8Z$EEuC@<@aB`6x{E9mzs|rQyiI@^rWV~+nH=u;Gm%Jctu4V~PspNO z8ds k5X z=dpTr8}zGoPN-9w(WAMY(QlZL5Zrk+v X=0fZl1QiRlzkLz(bn`XM KUmi zRR*V1Z$MOG^T)F++z=i!;|G=s5I!7w-D0nJZXVn?y_^ zyXd={jntTuzZoY^!RJ(P9GHh-9N1+l6pOhxt+5+p=4T9>9dB|vhYzgQg+<{=OSbc9 zDqe54NHuN&T5!$?DhQYANS05Y;(EJP0-c;X5O37HFjxML-4cs ILDdXHe=;Nyx?s1$q+uWPf`9a=>ePzXqRq9 Uh6aN@n2Tfo z4zjb~&$<-(L4Vtf$(FGdYI{Io&k$%A4nF(qX8}Xc8l_^21DEY}jLV9*&9#}e1etjV zFqwYAtk<%LigVoqwY&u*xc!FI!MWF*(Lz!df|5~VDE^(Qn2uEl87fk0Aek>7(COF< z7(NK+zUM+cWy94TRO!jgp-*-s>p{Qv$?dh|b||mxr#7ERG_-e^&o_&Bz~9Bz z^Wyg$yQ3=cTjm8pkI>sIX_B~~O+xd#$B4jXZr*4*QoBvSWw*W7+mF}H4Qd>~yr0iS zI}mRd!lN5)is}#KzhB1^Bnx@CBBLowqTy#mNEGda02})4)(43;GY2%yT1!;DTiUP1 znYAf8j#Q`#SgZFMezPth9t=rW>wNjW470hIO=JH9Xi(Bds&|)ra3SgVujjeHo9&bX zMfla>^)7S#y>raZc9WV#fnwT@zeKT@IQ pLLjDX5& Q%OP}4N@ z$R-h+F$3HFvVd;+{b@zBEU=K;cA)2+848ss4O%W5i3Gx$AS~aFe$B~Emm2W%bNl@t zZz)0mEH>$E_Dt=k*($O9X+4#((A&)p3o6_EpgPu|K?PXw=|-kZ9&XsyR&be|zJ>he zd->4zf;2E!Qk*@H2ZAHk!Z^E>;DA6p@~xiXh_>PkHsYUcpuV-p<<61^s)ZYZg&Rhh z0E{MS5U9+krW0S_hMFm(n>KrvixMZrpyWs#$!C}B`wd3oR0N|;MCflXUf2rV5esiS zeHg8yW4Ueh2XB1AuJh{n5$6HS@on(dTCnvRpKJpQC*4y}l#CjO=(vH?rRaXfi{+ zfeVy4Oy56a4jXZjCrlV$+*cQ{wtP-~K`iv|cMm-yBm@KpbWdHp3c!^&=H3sO>(etl z2Aa$_L7;QgwLG!1KRpUiBf_THMZ+1^prAmmcKt%49KaoQ19FD{1`nqREXdEl#3k_r zgr|vjqM6@zIZ-#g?NMSd>f^^M-qGz{V!U`T0v;7meJX=^QDL* HRUxr{cc1*!FZt_pbGxRcYV7Hb^2V+jh}AFfK0p3RH!0YRBBK8 zB$Ce!Aq^I7S`0*Io kp6Q`;j^)Y= =52;b + zCSA?28%_p)xhWq!1ylx&7KC*|B7Vo&qrdid@l!l4?^QWF38arCC<_)fLl#0dTe4_@ zQ-V<>?~jPOTjG1VWY}U9!}bP+*4(i8YP_zuI_A5D%BAd`9=pDMz8miZcnnen8cvF5 z4%50$3JgVx<62V1I{d3{q1j^R$3)YA-f3`#dP0ffL`{bCkX}5J)ccL>?3l{}J%zkT zy$I)L=W@~7n )dpuaun``|Qt{f%u9ts$nCFO!&0 zj6ZYvlH7OYgza}Hp^?d{!%IWN>4#e`5Ixz8#ZH8(0r_>Z(i*edSnkDpjCQvA!hg1A zkN-ynQ~&+nRKfCkrP<|pt-IM`O7@!H5UGy>y1^O8ek~}j!?ek7w&NYSk6AE!=d-%- z9v&>R&T{G!Pv-B+>id#nMDx(dYazrX-Pa7a!-b2Xwk3Tit^wR{Pu}59i@o%G&Bu@* zOxx9<*Am-j?d4!(L)YtU9-b0Y>^jXrFbIO4lvmk?^uusl3*izbo{~M&YGOrPn13U$ zE!K%CQt(v>e=!Hz%FA95{Icz8_t4xG@}}ODM>*wA{-w%50;WD=)wNZD<^>k>H5%!Q zmIQS)E89J&Q(v;_#!2V0NYc 2s&?i1MwhULmy z6n9+}$6skdsU8;or3T$V9OoW4m1Fy3+QIrrWeBo|S0U{2_seo?YpzqV8egLIZo~eM z_T7BL>8)z}Dc7#pp^Gl$e=auSylX(}q?lb8yN0Y9jmm#2tvYW%JLx~114**Uy7kFg z_>bMJjL(@jZG6+sxBTWDAfWkpuOcV^p(0p^D|mvaGiajgEm4nUwA#KWIi8IJZghtG zrk0sT 9KB4U z7+o_)>5kWOyV#$fmqbLhRyK(6F4nzc8LmPe6P$6*KSEQl_Z3f85jP*IOVr8*XXNx5 z@KU5`xuCYZ)pnXu41-|D%0`dUe0dgiy;8D+U=zBe5YhrkbPDI|ST)vShTeCb6?Evv zgS^bLgN2FiOJL3dW(W z;KR_;D4gLcA8I{t`VB|R>qf#z4LMf))C6&_KqSY9Z1xhME#bi7Hu@KjhZ?<>2jtU3 zA8FqkI!$T9cP+5fE1-=z^PZhKYU<&?R==QlHg6&g(M}q$7~=rSXCx_fb7yR2+~&q9 zV;eBaqjYuAct=xOJC( BpF@7O78_V=We)ovR1QE z3#d7@mf2&JD&-=1HU4301B&O6Cu~ zHJ> nv@deV%RX&&e;N?ew2t}UeTT(oe}n4#X87Ta zT*Gvu6xbV(sq-&@ZumwtOADuKXw2- c+ d8ZAW6%=~YTs7ncp{!asx~H&P6>+^KkU z1Q}6^H|c_Sxz2GWOI5ng6m0{t-B?1@c*RF#Q*gG1Q6W8vNAMEJwxQ{_T{Hu@Bs&!A zpOXm~20t0k{fSiPSt|gpmZ_CzUivayQeI^cQ*p{r2ll73FB2!Bkh9!WNBYNqNS&Cw zRB*c4&nK@pLh}AJ(+ tEOzxTA1iY4s~q=nm6WW_GWq$=x!fOPS@@R$ap<5*4_N zS^gOBY7ixo%*kjE%2dm@I2loA@O>C8mW>rbwW>e@TN6o&HrDOg@GL0Gzz%!WVUlX% z;&rc&kII9oYmPTZ6(MP)e4fP ZK#m#TPrC4cEOg(33t6s~V(6dt{aaxj=R z2+ ^*#fhwvEOrJI z@;#11*#|gWoIkx=-z_9c%`K|1be=zTu6p#PoD#rh&RWKifVKTzAs$H{Jw%mWfSKH| zmp&VdIRiTa!&*z53+kdt8N*Ls8q|+` b6d#Q^)Wo(M3P#{MZ13TmU#f$r3^4E PjJcI)-beL|6P{r5|3pwCB?jWi9sj0>O8NXzF(U z%g>{3=VHpj9642L<(tV$Z`c _j`l~IINUVjYsgZ{H&Pw(6f zP_7QsW<}z&$d|9OXEbXBzW%IOD@Z1@R&*!UN1wI5@;)Y(wn$iPs$CHq>}JyPZ7m)q zPldS(U6Npg+rd|op5ih;*%GhI7(g@jj4EP@Sz{x?xO6GsM@;8Qndv@ze59f#hJ?Qy z@nIABDV{4~=IcS@u1RD2^prx`KU)`67MIP<4nD3QF41>LlVS8sNz7bOQa2sj37xQO zOPN5FeX6^d3T&=ObMn|3E7>bCempvj{DoKH35u|^NGJ4ZlAp6Z7Tpd$mISzI4F @6)U9^jmevkJIe7~KfP#A_%J!D z*2q~ou}$|EeY?Hz`!BM(U#yG?B(Ban8GRuyQ3okhb7gDwZ>@fC;Y3>JvFs;A=!1!3 ztnn~J^IoF3$-^6cToIP8gC%SvEd84_rORY$rBT}tt^6$=XIi knWo zfpCQna0~qfvrLWf9WW(D+oe*fV#7A|bQiZFhn!*KhsH>U%cHBSd!a17E+lq%nPGa` zYL_!`{7h6ls*?I@KH7Zs`oH9RQL5~)fx%pGortgPAtYhya8pJs=i~o-g)D)K2+dWW zWj1D~G*kwgp>$$jIKT8UC(h%=@#j3=Gmp{CqYmUgX2n|gkuji(CH&y!+Uj hY) zIRAe=`~*8$DCZmqyFkwLU@B*m6#{wT1S|ozoECNv>?HXgoPJYV1W`9pkeQ;U&GFdE z6wf`JuQkV>x^-DN@F!Tcacqb&?oC>c&}K~Bw^e!a`2COl-}c0uj3$7FAQ_19uoB@5 zeaa67yyUJ}ZSb+PH{gd(#Mm7Z{;;>KD6KQUEId!*<>NclN**ZGA>2v LmzGynbP+o zClf)4zO#aMWbE Ow|$dp6~S7?NI+}S;4gG9RYTxpb~$@+pRW$b0c98Z^3(4?@D eW#a4|y2{UyN>0X#;G;qY@Ks!1tV+Zn;6DKFk@cYf literal 0 HcmV?d00001 diff --git a/public/favicon-192x192.png b/public/favicon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f4bf2e5be0b4fc842b17831a9242735357b2e508 GIT binary patch literal 14413 zcmZvjV{j!=*QifUY}>YNPV9+oOl(^xw(ZGeVoYqC6Wg{k$<6!T`s&ubb$@jCkM7!C zYxmy0*IxBJk;;lvh;Vpt0000{Mp{Dk>)P|50|x5rth#Os4FK?9$ViB)dFEXB!KUbG zdL2G({aG+rT&zl4Px?KWW~ZH0h0`(272OL490KbD1O<|ckn95#Np2jeGFiY}sN`#V z#rxQ}D2!mhOy#LU2XBC$w(x7qX?C!qf4iC&7S;9j*L5)Hkl~ zyqMJ8JQgdTx;{VN0$^F=pp20u0U`?5{E7|^SHS5zELhSs04NTuR82ULERcw7jHWb6 zV*hpvKyvr=o|mWk>zf2(=>kolF@TZ`526;j$6cr-WsPung_DD$G5*sc*k$LbaiY7F z*F-+}7;1p$JqzaUIKG?Z#kUK)2aZgqIE61BGzD2JPumm-f|Ixpw)wRn!RF#Bf35Lu z5W|LA2e4I_6_k@uqAmRkzN}ZF7Ea|!zXpQ~-+z-k!SIa%5K)sg_WRb91_N#!peG@b zPG8CdRf{Ha<>}m<=qoU4l{7qBK;;$lp f#62tmNE}Y$oXnrx2cETQ(wA04I%1+S;z&JPHQ92<^Pyf? z#QpuT2N6731`RsC)Tne*s?spsA`>eCJ4%_Jso-{QLq+wlEv3hR6O^s60d(N@EQ*2h zxca1~OeG%eI~5bdL0!=x2r=1F#YaIvm$-y5ZTk4TZ=Vfk_G}gRqn6zpt0h $ZEDlortijFP~;r(~}+zs&s(~;2}fX&C14hJHvD2rSS&GfjyDa)zy`A_2vL{ z6bz()8JZ@YQ_{v!^HlIh+IC-S?)JIf?oC{=Cz`dT^8XqTCbS3br727Bq&!*Z8)n^% zcxd3Jik%x4WOkTh>rA07c2-taN4A)uD`J5o_KO44M|YNRq+bKVn;O+gt=z9Eo{X54 zN|Hs9-jVI{Om$3`K6T7xcG@C3UZf%NIZVu$HxL=zlN uSeEe))>S+O0*;FuJR+!*v^~%NGo2Q~R z5&f=H{ukem_{d}CNGoIc_wRHt?WwBy6>4;!RzH&3RPkN20Wnn^UytCCy>g&-@=$%m z!1Wc&R($GIsQI&f5V?W3eFKoa=|4U`GTr)peis9I+``LN7{!INTH-}rL8G1&1gG4d zHQNQ4e8sK#_D?LL7+;#bxd;OeGH!2cpcGEF;&^vx>S99d_7fzXp&E@pesFuSQH1yt z-}awLU5f;*8@#2%+63X0>d-O4vz=FHJLtH-RMpz2{0%CX4xqvaxAy8lT9iHci#VHK z-vo=c^pUnyNVP((OYkGl9gR-~VB3&Y0w315g+BYXhNkU=R>_ajNIBP~#+2>=P_}9R zLl~ln4)6<#8r<~~Me)^#aT4t~_2}kGP!+P*{>v{{I`=)73Zkh0+)p@vx?4f^MNa&q z#iD$i-YHd8XiKiE+}B}^?T)bSISPpvx>E-OE-X6~Wo!SJdBg2+oc&Wj*Av_DQ`(hN z_IH83_iSt01>^dUj}P016|>|c#JW*-YWX65^vD5zXlUq4c0#&Rq}nH?bXEU&5EC1f zQpf4E{jOqPKt)n`2CMhZD?@AXYl4Ek)J(Q4t zWHPiLKWjj?!@`|GUOq=q^xgLH9UwwR6%Yf9!U$3*T{u%p<#nxx6dCr69^A!LmHiMq z$#`ciBZ1%~9k@!7Ej7$J7XZrz-qs|l3K>a;4qGV26qD{s%fL9PSE%U(u!U`#y#U(c z@{evIM6Uz9I3izwb^Z9Jvg^iEEFKx+)XDt2X4a{Y@K|QSBrVb0F(C0=);LrGJj4wC z3DU#4(R!@`nXEzDvxir1`F?M;SzPj=IuXqzwulV5As^YyWvhfHz82#p(XzU`sRg zP^3jnYtAd*_iua`(X_dtjz_v*Uoy((#Z>+X2dkPeNwsUSQA<(RGo_3@GjY;FQ# k*jkdIJHdLm z>dII r;=u*2(ob7fD=Z~IWFg&R!-Z{87hMe~I>dZmmb*T5 zZhfzz&Y~zrzc^4RRfX4I^6NA6E?zPn;L!%1f2dY2>gd)idEgTfrwfxPE-<+e91(+l z)_l*(F`e*+*+af(eNtT55-{ld(KJ6le-|MJS{if@h{3&uHW(%lKn)0t)3iFA&fnUy z<;ss!$j4!~gQG-?Tzq@Fisw2@pw 39nj+#5I%&=jblH(V egnq#6?$Be}^7*Yo>O|1DOH*nu^mrG8y0_o!n51hLZ*7b{tW%yleY9nXW^lt1 zfs?Do=O^THr{x1(Mn}tcku!bd13K0Q{Bv4nmxLy*|AbCeJ<^~{-D!R;Bsck$R0JOn zIyRNy5Vp6AnKcGI^XBt Y)}v65Llu~=aNvW}`0R&qSVJHN;HWRi@@6fwKI(@Ish zxV=Yh3d|TgC5GrlESt&~JpUTCe3)jpK!Jpv@Cqw1zSZg1w>NB#?8#V(l E&;*%(#LC^6=3Qo?erbbWq!QoC(cF*WJbe;zWBQg{J}9zWkA`AtvyLm znA5tjB(^B@j}+DRuV27eZ*TAHXk#GH*y$$}=|B VhQnG0Fz1OGB@;!@6lu$1%AYorkU%Ehc`_L|#)^1Ok}a*bFOr>& ^4ki)L}h*18_ p`-CWQyZ5t9XNI*NSwc*O3*;el(*Odzcb!CGfJj*EEy%p@+4qztA9hwTp|3-_|Az+z|`ZL(H{nV%WPk zU?E{kbs`8=xptbuQ6M-`v|aevZMFZ>PuiGb7x~f4GNMY{zb?O@FtOD6)x &zA}af$TDkzA z6NZzEFjwNf0e1hOADA{QX6#=Q*yWXO+}pEubC14c>Nx#m#2?m51(CLMFPFJjs#-yi zDVhjdLi{zYQ$Q_S-1gollH%zC5EP{9;Hq8;bK)erRxb)L^s%Wn_TqFdNWld{OJ!O~ z)|uy)aoJ1vkf3U@Mgs}1xt#m7Hlkn>TQD>+{w`mwzF 3`x-92rTGbRu)vgCG&0$tl`SU7 zoBiPH=6djn8{&GHePADfB3x}U17*gz_6~*1Mn@T>h&ZjA*Zp)RZ#NEg^%n2na`J+# zXAK| 44FWfB~?hwV<{TG_pRS683DyaVfhV>yPM$c z!B$xDelw@%jg}l>Z}kbEZWj>uiei*(aH<6}a;R@RECc2=)dNhlLpp<;woEjih^zg2 z5pAd{9a`WN_!a?q%g(9W@nP5zgM8{<#mL6DfqfT{P(!Y~iZbJCZZYGZM5(bWbhP=O z$IV(6KFhZ>SHtvzH6Q-2aD}b?vm{6!R^`iyDvQ_|>w0LUAy62^$sNM*fhbJU6!+b& z$4m&kZp;~Xra%!1`pGjwDoBVEP1021W;#~dA_fZ-j=~b;aamEVie#-VG}J75hJHI# z5E@BnI2U?EquU0A8aqWpGynSaw4n%A=OQrWci0tE()OuKoS03p%ts7X30_!|UUPwn zx3wj|1)3=6@z|ATReTlf@1P?CfC1Va2QhdOI27ir@N9tDr%0MA2!8U8SHf=DFN4t2 zJg0oU^JmP8)4f>e6@NjJt4L+5I_#U4jMl4%8aEuU$z_wPdhYg-*d6MbYO&7hWH{l> zi 3t=s`Z}a@v0NOX%^AZwRF#QGvV5qW^ra8-Gr3D2AAYe^twO* zGY>kT3 }S_Q}~lp%l$Cl^lqrTuiDNq^kTnU2F4TC zE^PLj52oJJpbh{Oa kSNN=t0jDxu}?84OmEs8r6}n1nps8Y*%aafhJpVH z7+dZK;0hABJY%WBEi^4Hnk-gBvedi(qt$Y%wA1E=V)!eK2ncCN6ot~HnS9$+n*;Pd zG7Q>;)a-HC0EPHm4m5B1A}!wTwGHTeih5Xq+s7HBA9EZEePwoXp(vj<$s++U5VU7n zK0DJY$E^UiYX!pb49$Xvc5_}wg1!c&&A v2PyH1sPLF0RMs>UIY@pMZV;%OKh(+2D;r zZ}@_$cOQ{2Ubz~K;;yx4!czs=LzU7@A7hK^$ROg)_)nh!%~mTs3m8#9x ;XIX1HZ~5S7EL<4fHs!hhY0ibpctFR4ndc?72&k)~Gly^gvlU}< zlbvN>BG@U7pK&1j0t4*hJ=zN0*@0GOtcfr$kKeP9pi)A#-({niTe@C_#*HCzCUD#5 z(V?>Iu_2rNgD{BQiF5)vZj9;!0|xR#-wDd0m}=RbPB;^WZztA8=58gRqp=Z7j8i!! zwFso-6#}EAKvM*3oe*nr$Lez?an%^#<=v1rpXwWH`V3^u77F{ &ivyTa~rZ;_D*CYMrKPT7+0d&<&se7!Q6H1{~m1NXb&?_S_L_1EjmWUY=l z(_*=4$#+|N%og8G-U%ag3UQ5%KG1?u3xt$2i157%gL$7d{q1F0-X89H6FYKm0B!tI z%W;zA4N0|zL`;s>c 2%-Rp!R zK@M%M8WlLqfFX(EDy@D5Knja=l@zZxB}UWC&Wu`Pc=5u(VsrcE+o@fv)qmUp)Xd<6 zQA3BH@`Oyw|CAvVx(J8z{ZD`&$Gm(PzwJW`qV)M4!p%+AXJd_q726>huzy3A4D{S` zlWFSV{|G9E4T_75?~}=m&I>vd9VncD08J8!`_J_yEYx|{^YA5$oyO2N8aeaK%ifE% zyd+$w1=o5$AZu=cJkL4cddnhvlHi#aRC^ye*XHG0DQ>nb-CT!d?t9&%hSW^@B7%Et zEl3eA@HC-e=EBsqd>@BVq}>(8kGKx#_El%Zwk3c)`%75ZhTY0zpyeUUtBfW!90Lo7 zOireLC&Cukk0~w+epIUAo|1^-zJ9a3s*+d{FRhQF_@b6;Og`JyUa^AKsr93ARgQ0t zgw>F6IO{&tzZUTmn5$<;SuJpE0%w@u)Hy!w$uftMT!cXn-M+#M&86}CDHS3|*iq#H zD9zc6q>@vdwKauWJo2Fu^)#@-wqA}(-FiN$ ?c3(BcMoIy7I%?W zh)aEEFa27sf7@$K_cR|`@5bL-Y4_G{UH&53ULFf%VOnG>XcI6Df2(Z9eskXnyAAT0 z{b?tP!eO+gUE*R{9I(BZ;qlreZT9^dT aW5wf6so>Qa*vg5C)ZNSIfbJch^+970y(fWNMc`*i-k0^;U8oXu~qHcaG?!%Zy zr?(Ofgqi58`8|U-Gn?R1zXQ=sT+MDm$-eT>KsYU!J_GTUTVu?$V%Ap|wfs&(e^Za= zE@LUa@O(c?4MQJ%v_UYDR}wiKbQ))({;+V(gL#eI*ty3}tHu1mkq?LBgDbd(ekvs1 zmQO<#4p+pq{5R{CNFc@Vds8chqS}m5P;k$K?`=iaaSDtkZ$CixY z@O2JjTmU4IcbZ=4$3e~Thr(7+?(qmK!))dgOv8)*u(%Cad0;1hSBUe0^|H0DrGXnu zuqK!8$zrSmnT*i7EmnEW3p&IH1>&}RSpgJx1KQxA1K7-hlY4YS_Ua19WAlXwPjy^n zIouss8?iXP5bO!!NlCh6bS>JB%IdADfXmZbQ^>FE`Qi5z8a#3DuoO0tn|m A%qFHRSC$v+sR3g>67{OH`EJ* zd+Md#*5Oa2$Y5}1ijf_gsz%-tWPMlcP56)M=qaD>x^}*$Hlxc1LAl=NJZo!%S< 6sE#u=N)TJP3bmX})hwi*SKJQkI6G zF-!W1v>7}1m)Y7fn_Ndqx3u``v_;#t&+fhZ&8M9>T ze7OVQvbBKhj?5!@$vM8<_K~K1k(C _Fd}p{$`KSL> zViAkLYQuF7%Q$~#!^nejmjoYm?QI_hP`JKb0VmleT&rz)-I-z88eYladd4cOS1vzy zi)NyNN0?+AE;3{~fYfz_)$L9K`+Y@*gU+Y!-;$vi+c4tO+&z9WZ_6^1z;BG1hW*`} zy=Q(z9RT0vy)|?;o+4SiKK>`<0XY5+g#suJ+q1cWm+1nHpo_FQV%^$L%eD96I=k4E zaxutI^s48oc*diU2_9=;0D08mq?Q~$2y)G7#uiw~%tv^npCewmX^T(J!n-`RRH&33 zg9pEDbsO<%QQ{5NxW($#7|IEg9*RDt1AdRxavj3bI%ENPgtffCea*}O#!bt@0TlSN zxU>{VXov;$laACNQKom0sC=gx**#un @_PgaYNCMNtHYe&NU zDbL73NPu{%yy1%BXuEEMWIA5s0M=^!=leo5+G?S3(U78}nz->w>6K_b3jovcpbVP2 z73D7d12x4KcURY#6+2x(S8oCH2Uwg d?MeX6Pt(8*4$)3*YQjpz=n|uo}iZck_b{Wm6u^Qn9Hm_#J z3Ic}^67K*}If+}A_Yp@h0*M!vTW1xjyAH7;C-CBzGQ6Wn3@1x~lxY+d+?^5cNo`)6 z_|i)ASoI~?@NpBC=i?d<(fZE|6(+LiMiC6$-HfKt`1jp!TAsbSJKU9gUK{}@K8aI| z$Y<{I?fEQrK~&AZ2)8tnuh%s5<4VUHjJ{{uJ(2%Wh)7RTp#9feCNDmHFw5#WpJT6s zQN=+$%T}i5Qtu$=Nbsl~$gWA{xLOYZq$EKr1SE_fA_pkaz-WkV8>t)LDNb>q<1t2h zi<>kanl?f$jD-w$-4d52LcB{7F7E7nO@T>;WT!YJ_c*t3p24Lc?4-ET3 GwE zem_-6=cZT+Ni@r44vCJxtZkr|vvf8&G08|52>a0_V*G;3rD(X*W6HQ)4~v18PbpDf z>p~n4j<86{W&4Cu3-uG)Y5`nLiYJoOka{YvCL*hG#j0FM{5#VQovZ#TtK@rKJ+eRH z_32l^re$g#IN^sB^0)ZtSd4K7kFf6!c>{skg$5NpQ_M8f^gD^Nmg|ZFP2Qu9r-Axs zmCQ6A>GnAte+4kMZD6GmUhA$1h}UE^ag#d-8RhLVc_d|lT=VLC4L2Dv4OwD3ZyWzU z#b-MfEr33*>UlGW^xM_yu3@Fv4@|LEJ5b7a4e3ad>jK%a7Nf^OwlNE?${SIPAt+n# zhwRW`oxU@nP&IkOwYjn)l<2(poLb&3uXDJr4ZUu!46td=;(0QQ_UUrO`IyfpP{ozD z5li}@?m}}*!t07k%NjoEXfGZ$Zu_qIo7K%fBKclfN3`fpb9dj?j!e67mWmRdT{N1O z>Yl}#3*>QxBH!-|alDYMMtyQvLPGJ!Mh(L-VEM@1hmqn}Ts!Sq*xKN=0G82UfEEWJ zr)f?sJ1 z7jhWBG>Y})z)@)x3uB3_u(OMz*Bajj`$h#4i%Wh@kK;YT#wq-Q?6T|DQ6WKaxmbBw zDGX!9woHLFtf$Xutt=-Y-$K)~N^2R@Pxx3jIhchrxb!YFqO| PBuLY!r-w}fnpADbCC8o*kA}Yy+aKbRjAoURRq0V zi0#_+eg7FIrr;`#9$GfGppkp6hpaC2493#^({WQah%W#~Dpei~pZkxT%TFv~Mq5GU z`7(lRF@s{*llY>m#wXH5KI}Q>Or$?n&+58ix(hF*6z{8G7*lWP7|gHm8~i fFKe4x68r%zJQbgzh zrzi<)rzmdC(Fy~@Dd-ZxJqJ_G=AyFjOC8O~etv0};ciWW_?ucn3|!g^Y8H;!>W0bs z*1E!M-?d 01y_>32ZjFN6yN36 zRi32!d0^~X65d&=;@Jc+=nvWY1sofEH_sO-d* te3 z`ibtRxe130k zX7^Uag-?w!}2E39x1TTst%&?^lMZKDOeRNaDWkp9*jGP%!cr3(E<#Nh}# zjE;EQp{&P1DK?p)Bjz0a2g<0IjhjToxHyl0nmmo!{QCwEuLVawT*3(7a)Y%X*XOR` zb{RaM%T|Ta<((Rc@YuT+%W+2&+30E7VK9mNlezJSY)xMzA)=Nyg>=iWc!i`Hn6n37 zmeB|P?zh(*_tOmaiFZddF_>|)U~>3lX%mu_7cQze|2M1CzU8ejCubv!U%Wpi;zzd< z$?4nnhcV_(Fg4-3`XDV)9#deD1!S`Bt!@*D^T09S0zcS-B6@6!7F0)$t=-nINJMis z_DBIQTVlUHogvgB_6zmB2TYJ9EJ!8lv7>;`v7~}K!56g}1+~9nFkE_QrY}ttjv@+8 z{vsd;>`8jYs!*^o0CKaTjV@g pf>oKn*5aFgC>5 zAAl+ T$yZTaBT2@?^c=OEhOWRos7&o=@P`Cc(SHhS;VS zxK6upSkvo)O&LhG^`%g&Q1IN6`(nHi{(ie;^t{{&5NBJP-H#tD{L=ui{ccQGzZcvz zTnrg}>VMvOM72PfusK+YWb)fp3%fs4jJx`CrvkA#S+cSfIU4I7kM^>#sl@5@I029# zMkc!anEhbP{)Q?qc__8l!*qE87d?BgN5mxm{V?%fI;jns!1rF56yG>;GxTZ@$#q=3 zfe&G6=h_4()kM^YbUTzqCQ?s>r2TH3#;yZfrW{$c3TSn^M3T)(4pFRJjJc2%lF-)2 zdCm(1Nt)AJQp_pf!1+@gQlXepd{}E5diPizaE}d3pB|0}s==fs9(;inaM|kK%d7dI zM9U K?SO691bJLq{QKRW$5&Pxx=Q@Qa!S( _@`#!vz6M;Qn*UP5961xl(LH9rC^j;c35cG zjApqiiar%;TVVaQK|R#*DBVR*!^?6zNl O&^>Lx-VM^ zXS%_mNBxQNwKM+^E&U}oRQ2x2YfmD(XU)OD;jxRC>R25N;QanEy&kq6U%1TvPQBJ7 z{^03yKeJ4n#O6qh_a^G=i(~G>P90wAJ(UqDk#7b~G7kqy>o|Hlql~Vj%7u9yxUEAV zov(#_eo$ZaTyOg`XeEdgZWqk#6_iI1C(}?cl*}7j(nD+7Os|K&mVKppHPBo2Tt78Q zYzj%P?`CH^f5u%RSprV>Gd;dGtphfGwG9*dnm;)yqP(`dJJ6&lA-s>;ad1~7Yu*PR zTJ{{au $hGw%! n@)<^E-!xaV4jF zzJTy#5#`Nz#(+Dz`;L9&1YJMMk}(2}Fa}=mD(GV(Tmp}2vpcP)@we+Zo9aIN%^Rki zhSxuRxI(K@unD6p%k|dawswk6=~{4A8y@}4|L_!V@L-M*a$yfu;si$0b9D?Z=pU-Q ztwfBCI3ZN|;(Jy_uE>f#?+_m0fe4x~a%mQh{XWiGGspsetcfrGeF*PJeY( TRUnPCoTF>SEb(xiz*(=Jk%Zj#4!80A&hKWC~h~{0F+yw8RoH zL3&!se^!U$6{~;jQH-MA`@8W0g>m`!7Dqe0ny!wx-m3T=sjY7G`1f=B^R+t63VEpJ z>;)=Q>|ScJsepeft$H8AuXI_cC&+}QYsGt(P>g!sf`1L1@jKF%xLzjYrDnQu_{QwX zf+o*|Lll%YVbbyYA4ur!*<;FNzNM48-p2(vYAtsVb?p^C5%jZGXIMDU`w3lKVIkK0 z?RaG|S3mJ?AFG2&l#5mniCBmX07oDm`(!Y~zDZB&g7K$^=gJmsu1CMD5NgfwgDi;x z5Onj_f(sT!*m8rCrL&|}mv9kZlVoU_32||~c;)~d;`=Tet=}o6=y?)T2Pm0+a84#v z33-iot%d%i{<+2xxqKq5vhODsZkKS7cWR_qI2Uo{FII%$Q)5T6FMt6D&zb>K@_4)l zNn;UgA g|!;s0p`oK85m>doCSeMG39Y8Pv?_a*SJKN9yQu?t7p z+Vj3+ZEcG~zRzm7sKi9Sx#b5OCSPZT>~!fi +8(oRx&Xc((`-k@WzRJ5cOb9xJcnVYTO*8> z_a6+}H>A(!J#kkCtn+Pdoe6upJ(q8_9&3c&r`qWkcASV-$aZW+2fZ^Q9#^${_!b~H zX|-FXB>nc?_z1jVH^tIWk|L{Gv!x7Sv)vEN>USFo-jb1 m_EpP|q$=BQ-iMSV~v#`Ciq0g6Ahb{aP*0 zf;5l%fjv(ouGp_TIypA(JSbzFBKY~QI9ais7Gz>l0i8J1++gm8x)MgefgC0SN|UD0 zn&IKP wiaSoZWjYnB~_@)Hq0jpCl zG-s6;(Bog_NLA-(vw(8%C_R_mH@BRIL--MFtW#@btcVMFdATLG?4ZVCa-Zw_hJe`I zwJx~fi;-A7lN>xN9hy?8%)N`8cnFco&@a%F2?Op4bv9dVBn^o^db^Giv4s7IBwaVU zu$Q_q6aDY2lprM$-XtLoUI+1;djN6ltGl43Fx6*{q=>{hU~8Cw`!L=B@dCEZ%wI&J zdn>XsfCnmUoCxaXSe)4bpKgt4>2Pi@{3El9+Z{AY488-UF8i+_mL}qp+(eZsInzP= zFBRFC$1hx0o}FRxUoXgV6ebZKQ{uR{n!zMIHm@`d~@FVqF(vnC28(ML$dgb-7`r4T5GFdC1C#;RBm-ZPz}KOBcHA zN8yG+PATVCLWhlBL 1ax>1XYZ^8&yLhl<{7rUrdh zcp%O(h6xH6Nonk16hdVB^9v?aq&=v9tUtkFZ39fG_pX~G#hXjVivk)ign4+hFvWZ` z2WhYZar8A_<^M~o6=h2OdW~u9NiR(i;A2IK96E+N9?a$)Ul*J^4d4Kq!E_A?3Go42 zw{H|7K#QG4oMLu)aPQwBzYOzhl&|^NGpX}zOS#1boX~#g$KbFBfsVh3(O-{KHKQLQ z@`IT)sj?jrJ$7|ipjpbwPrL92Da1`sg~mKA>4%~$5v{LT>KAT_AewzXp>lW%KYUHt z4Uo_=f=1mKn{p#SjhZb?B;LHg(J+0Lk1YS*bl`@mFO+{pT4cGVL&e|X!81e<%t1h? zCY$@kXNH&a?OXkV_-mnDs*0;B>o>QoU5ATNoXpj^U6!uICRP69gTRKv(l*ZEi>ggU z+4Y_0Ccqva%((G4q&-MZl|2wEM%-6}+}4uMI7SpCdmXvSwTZ!@h7M2eSVN5) i0&aDFkMJ>b^5-QCxULDKry-J4^Z|Hz_F5RYD&yJg1qawJ<3A|=N$0CHj@G(046 zx$*43+w~Oo{QUUW(Y}x9q{T6W5FzMb#6)x@=1WG2sI#$u_tGAQQWH(Mi^Fd8`d+JA zq30t|Hth-%sKHyI_M`BuO)29dwdj8nq#7^(Ly+c!aQ(^57IAmIJEsuYi9rg5IOTKq z##6YLxyt5!k=1{_{jI(hFiqM6P0u%(26)}CVC qWwEz(R8)q$e#|# zNJf=}+3gC#q3*x5ycHhR<40}pCY)Das##R#%GNj6#&Qrzi=zJOD@h4A!3Po;rfQAK z^=vlAZ<+H4wYQPWz9t>uf9hs;M;lqoz`e8ZU7}jeM;w$iFRl5=xoqFozB+eJwqkmq zOgZL+!!P6e1f%$e^&NY8dlTzw@cI{6zlANPSKh}uN!UCq8N=k_ef33zb}t>?E9z3- zx(=Tg?-Y}z%6970K3M&RHObi?`=oKHu&ZBdOxwx-edvBsaQ_9hKl**9i-9Pe%6NvH z%)I}sHUC=^7Z>MO`s>)7%)22PQSpV1 ttbfhEO z=+8UwcgRo_y&9EhDTbj8vKO!4qfOM=?>vx|byFDWUm!G1=_a567hW``IH7w~&v;5+ zs_e;%M;*M{^_VVwyi*e9#t)#lu10JcT;{GM`{F}RPW~yhu`@O5`0Lz>5bl7Ps C9;1%0P z+41^IvZ75M3BGa<2V%J3?Y19HuXo=S{3n#9Rc(MDeG>O l1rJL~d`e5Swj{Y~WD1bWRtQ(C(RnmZUS~QfY|*;$(*5|g z15Xk9Y)5;rS0}?^BcC_?l81LIHOauP_@OulxBw9pxfUuv`;d7D*rK^Ljoy_yxB7-u zK%TV}!sgNdB38n`Tn9!8S9VlJmKWe`?~&kA;lwf;lGEjPS-$~SC%4eftx>8OvnEn3 zLlM$}K>&}nMdlL!56sBI-lxzB!M5nDOws_?X}-Ak NBYHjMjZP)y6_-YGB#yRU!sj8n%UkrbVuEsxi zS90y5s;p V_UXl{}_3_i1z9F76v9l~X>bycSHKN(vm zW9k81usxx{3DZZ@7-GKCv=548bj_4=I8J`EiKr4wqbS`H5{Bujn_uooUN+-Ez8w&S zR5s}(d&DKOB;_hi)qpuvYp7=MaCUZ*AUwsn5@pw&bimGnNlD`Ur>(h$QS*OVpIKT; zzw`T40Pw^+mTIr8YPPp#=xf+X#A*0^aC4Wh+pj%(AQK7ju<1q;8#iy=*J+o|!nf*O zs**y)ueQg5ux@Nzq;U*Hyu($aJak&4|KR^8&xMI#IeTNbIE&4jkiu!u{o+>RRZsB% z4p5FwC`IK8IAqW)XyIRik; 6Uye;WQy@S^cO zNan#wm`S#?{6~(#(+6&2O5CFXDkDK29oE>O5$l-E@_#(3^gce{Bd3Dn$$bfX=4C71 zz}{m(L`>=9rOi)u6MH#Y7u5eTwIY4P+)Zr$(-4vVf0eHmIyXw_jMd1$R5<8~kyKte z2tSgB`$OH6rip>I|Bvxi?3`#+ozz8G2axww7%($m`v0kD4Koppz04SKrtQ4UzV!ag zl9{08oRwcG-f5sbRoNSomC01uurMQmbEiWUgb*!2T-+nWtyyK+{MuT3&t1p24s2lH zF^;nAL-y>ophSv_EM?>f=bcLN+4R*3ZK;mLg0gHZ(5O5mSy|7%r}@=g$kU?htHcu? p9{!k#mowU^-Of%7j=uK^?t8lSB-29!`K1X0WWFg%RErq}|1X7{&DH<_ literal 0 HcmV?d00001 diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..74622df59cad7c61ff7c01b33edff024452e252f GIT binary patch literal 1441 zcmV;S1z!4zP) 4!d4ZD>&u6+bFMp-(<&5g$|(ABsMR zZ}vfa7pX#}Vo`#k5Smu3m|}`))qX(JkEF>a*_~vwJ2UsnnVl`0%_bPzU=J+J&fI%` z=bm%#Ip-1<7Xlyvc>KpOjHYln+za3_0NqNdW+F2An^MXqqN@PT0QkOGEcWH|`D=k5 z4S?ifi$Q9M2qqzwN^O=>z6#(eB5LG!O4ZZ_qLMnL)EIzcLWskYlaptwb ;~T!^H{a00Hg Vw-c(BM&1SQ| zRvJN-@kt~S>x2*|iKt#F (ZqMa%f0dacFoIfIS_~pOx&m+(hO-g% z%wSfTE6PVLo6Wv2gy^bp`<)gx!eIupf)EJb3ez+
T`v@K2KGd0n<` zpJ$j7LcGB-3I^N;oIt))mlyyjW--%$j>qGxbX_0joO8PdaP*XPAv9yjVF6>(b={3Z z*Y&+bl&VM~6|2!}tSE?*|GxCh<2-K`on4=xqw_;ZS%45>5NV5fK{Austfe=wsv^H~ z7IRT7PWXyTu;4ukP|}8Ua!{&RdX_enbWqpSjdaHoaOUPvDBOfo%v3NJc!7VtdQe!( zn=gPD5s9>5P5Vn2ANvk=?hG`|fRsh(+Incw7HDPx#i G;^bS!7PGJInUpR>Ru4Xi@eH<~X2kG^%qoJ*UkyFn?%?zVs=N61ze+Sw87ij1> zfK}<8NJd66_)`!1k2mAW$fsD>`vQ_p+u#;+3-%MUYn~a7e<+f~jf-C+>s~ u8?ob&M%dGn(4tc?nxb$du(~ye=5E5tD{)-8@F$A7LAY}c#zua`J?j9oAr0xe zOkBVc+)UKmszQe>&fxbGA7D K>2%9$kgj_TtY*Gg=b#WaART; zf1EXuZhHl>wa;Ts?_nghX^dYy1uYz}6#|>fC3h|xs&fDjrf?WjX9pmfZ0PzqWG)Y& z|M)S4bRCxljv_kJ10f3-KJyD6J$C{a_zc%a)2KJMqj~dgw52oX`{*F-iED_)6HxM2 z74})orerdCgovI~CG&0_T#lFHWU=+Zuh7!|B>MZdVRYnYgd+wb(FnY`8UJ`{5d+0S z0n(EQha^bEpvZ!eFyZB|!nS7v_`KD7M8w|zlJ`JgxiVEm2A-EkBGH9d>^=;geiPZu zX(Utih!|17CDRNOB4YT@;YbA0cnlOW{Us1(Fgtw-MVDa2t!lWxzTnrlWGd!N#)*iN zjHdHRt=$XO*g>TY=XX ; } +export function App() { return }> } /> } /> } /> } /> } /> } /> } /> ; } diff --git a/src/components/CommentsSheet.jsx b/src/components/CommentsSheet.jsx new file mode 100644 index 0000000..d27771b --- /dev/null +++ b/src/components/CommentsSheet.jsx @@ -0,0 +1,81 @@ +// Author: fruit.com.cn +import { useEffect, useState } from 'react'; +import { Send, X } from 'lucide-react'; +import { appApi } from '../services/appApi'; + +function formatTime(value, htmlLang) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +export function CommentsSheet({ video, locale, onClose, onPosted }) { + const [comments, setComments] = useState(null); + const [error, setError] = useState(''); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + + const loadComments = () => appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + .then((payload) => setComments(payload?.list || [])) + .catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); }); + + useEffect(() => { + let active = true; + setComments(null); + setError(''); + appApi.comments({ dramaId: video.id, episodeId: video.preview?.id }) + .then((payload) => active && setComments(payload?.list || [])) + .catch((loadError) => active && setError(loadError.message || 'Request failed')); + return () => { active = false; }; + }, [video.id, video.preview?.id]); + + const submit = async (event) => { + event.preventDefault(); + const content = text.trim(); + if (!content || sending) return; + setSending(true); + setError(''); + try { + await appApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content }); + setText(''); + await loadComments(); + onPosted?.(); + } catch (postError) { + setError(postError.message || 'Request failed'); + } finally { + setSending(false); + } + }; + + return ( + <> + + }> } /> } /> } /> } /> } /> } /> } /> } /> + + > + ); +} diff --git a/src/components/LanguageFlag.jsx b/src/components/LanguageFlag.jsx new file mode 100644 index 0000000..a968b00 --- /dev/null +++ b/src/components/LanguageFlag.jsx @@ -0,0 +1,40 @@ +// Author: fruit.com.cn +// 用内联 SVG 绘制国旗,避免 Windows 等系统不渲染 emoji 国旗 +const STAR = 'M0,-1 L0.2245,-0.309 L0.951,-0.309 L0.363,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.363,0.118 L-0.951,-0.309 L-0.2245,-0.309 Z'; + +const FLAGS = { + id: ( + + ), + en: ( + + ), + zh: ( + + ), +}; + +export function LanguageFlag({ code }) { + return {FLAGS[code] || FLAGS.id}; +} diff --git a/src/components/LanguageSwitcher.jsx b/src/components/LanguageSwitcher.jsx index 4e8f778..5c9ed9f 100644 --- a/src/components/LanguageSwitcher.jsx +++ b/src/components/LanguageSwitcher.jsx @@ -1,10 +1,43 @@ // Author: fruit.com.cn -import { Check, ChevronDown, Globe2 } from 'lucide-react'; +import { Check, ChevronDown } from 'lucide-react'; import { useState } from 'react'; import { useAppStore } from '../app/store'; +import { LanguageFlag } from './LanguageFlag'; + +const languages = [ + { code: 'id', label: 'Bahasa Indonesia', short: 'ID' }, + { code: 'en', label: 'English', short: 'EN' }, + { code: 'zh', label: '简体中文', short: '中文' }, +]; +const titles = { id: 'Bahasa', en: 'Language', zh: '语言' }; -const languages = [{ code: 'id', label: 'Bahasa Indonesia', short: 'ID', flag: '🇮🇩' }, { code: 'en', label: 'English', short: 'EN', flag: '🇺🇸' }, { code: 'zh', label: '简体中文', short: '中文', flag: '🇨🇳' }]; export function LanguageSwitcher() { - const { lang, setLang } = useAppStore(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0]; - return+ +{locale.comments}
+ ++ {comments === null &&+ +{locale.loading}
} + {error &&{error}
} + {comments && !comments.length && !error &&{locale.noComments}
} + {comments?.map((comment) => ( ++ {comment.nickname?.[0] || 'Y'} ++ ))} ++ {comment.nickname || 'Guest'} ++{comment.content}
+ {formatTime(comment.createdAt, locale.htmlLang)} +{open && <>; + const { lang, setLang } = useAppStore(); + const [open, setOpen] = useState(false); + const current = languages.find((item) => item.code === lang) || languages[0]; + const title = titles[lang] || 'Language'; + return ( +++ ); } diff --git a/src/components/MediaCard.jsx b/src/components/MediaCard.jsx index f08238e..2ce0147 100644 --- a/src/components/MediaCard.jsx +++ b/src/components/MediaCard.jsx @@ -1,8 +1,13 @@ // Author: fruit.com.cn import { MoreVertical, Play } from 'lucide-react'; import { Link } from 'react-router-dom'; +import { useAppStore } from '../app/store'; +import { daysAgoLabel, formatViews } from '../lib/format'; -export function MediaCard({ video, compact = false }) { +export function MediaCard({ video, compact = false, to }) { + const { locale } = useAppStore(); const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`; - returnsetOpen(!open)} aria-expanded={open} aria-label={title} type="button"> + + {open && ( + <> ++ {current.short} + + setOpen(false)} aria-label="Close language menu" type="button" /> + ++ > + )} +{title}+ {languages.map((item) => ( +{ setLang(item.code); setOpen(false); }} type="button"> + + ))} ++ {item.label} + {item.code === lang && } + ; + const target = to || `/watch/${video.id}`; + const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · '); + return {video.duration}
{compact ? {video.title} : {video.channel?.[0] || 'Y'}}{video.title}{video.channel}
{video.views} · 2 hari lalu; } diff --git a/src/components/SectionHeading.jsx b/src/components/SectionHeading.jsx index 0e0380d..35c0474 100644 --- a/src/components/SectionHeading.jsx +++ b/src/components/SectionHeading.jsx @@ -1,2 +1,2 @@ // Author: fruit.com.cn -export function SectionHeading({ title, action = 'Lihat semua →' }) { return {video.duration}
{compact ? {video.title} : {video.channel?.[0] || 'Y'}}{video.title}{video.channel}
{meta} ; } +export function SectionHeading({ title, action = null }) { return{title}
{action} ; } diff --git a/src/components/Skeletons.jsx b/src/components/Skeletons.jsx new file mode 100644 index 0000000..dd03054 --- /dev/null +++ b/src/components/Skeletons.jsx @@ -0,0 +1,104 @@ +// Author: fruit.com.cn +function Skeleton({ className = '' }) { + return ; +} + +export function MediaCardSkeleton({ compact = false }) { + return ( + + ); +} + +export function VideoGridSkeleton({ count = 6, label = 'Loading' }) { + return ( +{title}
{action &&{action} }+ {Array.from({ length: count }, (_, index) =>+ ); +} + +export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) { + return ( +)} + + {Array.from({ length: count }, (_, index) =>+ ); +} + +export function CategoryChipsSkeleton() { + return ( + + ); +} + +export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) { + return ( +)} + + {Array.from({ length: count }, (_, index) => ( + + ))} ++ ); +} + +export function ShortsSkeleton({ label = 'Loading' }) { + return ( +++ ); +} + +export function WatchPageSkeleton({ label = 'Loading' }) { + return ( ++++ + + ++ ); +} diff --git a/src/components/UploadVideoDialog.jsx b/src/components/UploadVideoDialog.jsx new file mode 100644 index 0000000..9498dc3 --- /dev/null +++ b/src/components/UploadVideoDialog.jsx @@ -0,0 +1,100 @@ +// Author: fruit.com.cn +import { CheckCircle2, UploadCloud, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useAppStore } from '../app/store'; + +function formatSize(bytes) { + if (!bytes) return ''; + const mb = bytes / (1024 * 1024); + return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`; +} + +export function UploadVideoDialog({ open, onClose }) { + const { locale } = useAppStore(); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(''); + const [dragging, setDragging] = useState(false); + const [submitted, setSubmitted] = useState(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + if (!open) return undefined; + setFile(null); + setTitle(''); + setDragging(false); + setSubmitted(false); + const onKeyDown = (event) => { + if (event.key === 'Escape') onCloseRef.current(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open]); + + if (!open) return null; + + const pickFile = (files) => { + const next = files?.[0]; + if (next?.type?.startsWith('video/')) setFile(next); + }; + + return ( +++ ++ + +++ + + { + if (event.target === event.currentTarget) onClose(); + }} + > ++ ); +} diff --git a/src/components/player/VideoPlayer.jsx b/src/components/player/VideoPlayer.jsx index 6e24bd0..0408066 100644 --- a/src/components/player/VideoPlayer.jsx +++ b/src/components/player/VideoPlayer.jsx @@ -3,9 +3,11 @@ import { useEffect, useRef, useState } from 'react'; import Hls from 'hls.js'; export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) { - const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(''); - useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]); + const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(''); const playedRef = useRef(false); const onProgressRef = useRef(onProgress); + useEffect(() => { onProgressRef.current = onProgress; }, [onProgress]); + useEffect(() => { const video = ref.current; if (!video) return undefined; const flush = () => { if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true); }; video.addEventListener('pause', flush); window.addEventListener('pagehide', flush); return () => { flush(); video.removeEventListener('pause', flush); window.removeEventListener('pagehide', flush); }; }, []); + useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; playedRef.current = false; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]); useEffect(() => { const video = ref.current; if (!video || !ready) return; if (autoPlay) video.play().catch((playError) => { if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`); }); else video.pause(); }, [autoPlay, ready]); const playNow = () => { const video = ref.current; if (video) { video.muted = false; video.play().catch((playError) => setError(playError.message)); } }; - return ; + return ; } diff --git a/src/hooks/useFollowedChannels.js b/src/hooks/useFollowedChannels.js new file mode 100644 index 0000000..87ee8b4 --- /dev/null +++ b/src/hooks/useFollowedChannels.js @@ -0,0 +1,29 @@ +// Author: fruit.com.cn +import { useState } from 'react'; + +const FOLLOW_KEY = 'ymb.shorts.following'; + +function readFollowedChannels() { + try { + const stored = JSON.parse(window.localStorage.getItem(FOLLOW_KEY) || '[]'); + return Array.isArray(stored) ? stored : []; + } catch { + return []; + } +} + +export function useFollowedChannels() { + const [channels, setChannels] = useState(readFollowedChannels); + const toggle = (channel) => { + setChannels((prev) => { + const next = prev.includes(channel) ? prev.filter((item) => item !== channel) : [...prev, channel]; + try { + window.localStorage.setItem(FOLLOW_KEY, JSON.stringify(next)); + } catch (error) { + console.error('YMB FOLLOW STORAGE FAILED', error); + } + return next; + }); + }; + return { channels, toggle }; +} diff --git a/src/i18n/about.js b/src/i18n/about.js new file mode 100644 index 0000000..dd544a6 --- /dev/null +++ b/src/i18n/about.js @@ -0,0 +1,93 @@ +// Author: fruit.com.cn +export const aboutContent = { + en: { + kicker: 'OFFICIAL YMB SITE', + heroTitle: 'Short stories. Big business.', + heroText: 'YMB (Your Mini Business) is the official home of the YMB micro-drama app — a mobile-first stage for bite-sized stories, and an open platform for the next generation of creators.', + explore: 'Explore dramas', + watchShorts: 'Watch Shorts', + downloadLabel: 'Get the YMB app', + iosSmall: 'Download on the', + androidSmall: 'Get it on', + values: ['Mobile-first', 'Multilingual', 'Open platform'], + whoTitle: 'Who we are', + whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.', + pillarsTitle: 'Where YMB is going', + joinTitle: 'Creator onboarding', + joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.', + produceTitle: 'Self-production', + produceText: 'Original YMB productions get the tools, distribution, and audience to turn a good story into a hit series.', + computeTitle: 'Computing power', + computeText: 'A future marketplace for rendering and AI compute — buy, sell, and allocate capacity for production.', + roadmapTitle: 'Roadmap', + roadmap: [ + { year: '2026', title: 'App launch', text: 'The YMB app goes live with a multilingual micro-drama library and a swipe-first Shorts feed.' }, + { year: 'Next', title: 'Creator onboarding', text: 'Open registration for creators and studios, with publishing and audience tools.' }, + { year: 'Next', title: 'Self-production', text: 'YMB Originals and partner productions, supported from script to screen.' }, + { year: 'Next', title: 'Compute marketplace', text: 'Sell or allocate rendering and AI compute directly inside the YMB ecosystem.' }, + ], + ctaTitle: 'Build the next hit with YMB.', + ctaText: 'Whether you create, produce, or power the next story — there is a place for you on YMB.', + ctaButton: 'Start exploring', + }, + id: { + kicker: 'SITUS RESMI YMB', + heroTitle: 'Cerita pendek. Bisnis besar.', + heroText: 'YMB (Your Mini Business) adalah rumah resmi aplikasi drama pendek YMB — panggung mobile-first untuk cerita singkat, sekaligus platform terbuka bagi generasi kreator berikutnya.', + explore: 'Jelajahi drama', + watchShorts: 'Tonton Shorts', + downloadLabel: 'Unduh aplikasi YMB', + iosSmall: 'Unduh di', + androidSmall: 'Dapatkan di', + values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'], + whoTitle: 'Siapa kami', + whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.', + pillarsTitle: 'Arah YMB berikutnya', + joinTitle: 'Bergabung kreator', + joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.', + produceTitle: 'Produksi mandiri', + produceText: 'Produksi orisinal YMB mendapatkan alat, distribusi, dan audiens untuk mengubah cerita bagus menjadi serial hit.', + computeTitle: 'Daya komputasi', + computeText: 'Marketplace masa depan untuk rendering dan komputasi AI — beli, jual, dan alokasikan kapasitas produksi.', + roadmapTitle: 'Peta jalan', + roadmap: [ + { year: '2026', title: 'Peluncuran app', text: 'Aplikasi YMB hadir dengan pustaka drama multibahasa dan feed Shorts geser.' }, + { year: 'Berikutnya', title: 'Bergabung kreator', text: 'Pendaftaran terbuka untuk kreator dan studio, dengan alat publikasi dan audiens.' }, + { year: 'Berikutnya', title: 'Produksi mandiri', text: 'Produksi YMB Originals dan mitra, didukung dari naskah hingga layar.' }, + { year: 'Berikutnya', title: 'Marketplace komputasi', text: 'Jual atau alokasikan rendering dan komputasi AI langsung di ekosistem YMB.' }, + ], + ctaTitle: 'Bangun hit berikutnya bersama YMB.', + ctaText: 'Baik kamu kreator, produser, atau mitra komputasi — ada tempat untukmu di YMB.', + ctaButton: 'Mulai jelajahi', + }, + zh: { + kicker: 'YMB 官方网站', + heroTitle: '短故事,大生意。', + heroText: 'YMB(Your Mini Business)微短剧 App 官网 —— 一个为竖屏短故事而生的移动舞台,也是一个向新一代创作者开放的平台。', + explore: '浏览短剧', + watchShorts: '观看 Shorts', + downloadLabel: '下载 YMB App', + iosSmall: '前往下载', + androidSmall: '前往获取', + values: ['移动优先', '多语言', '开放平台'], + whoTitle: '我们是谁', + whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。', + pillarsTitle: 'YMB 的下一步', + joinTitle: '创作者入驻', + joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。', + produceTitle: '自主制片', + produceText: 'YMB 自制内容将获得工具、分发与观众资源,把好故事做成爆款短剧。', + computeTitle: '算力出售', + computeText: '面向渲染与 AI 算力的未来市场,在 YMB 生态内买卖与调配算力。', + roadmapTitle: '发展路线', + roadmap: [ + { year: '2026', title: 'App 上线', text: 'YMB App 正式上线,提供多语言短剧库与竖滑 Shorts 信息流。' }, + { year: '下一步', title: '创作者入驻', text: '面向创作者与影视公司开放注册,提供发布与受众运营工具。' }, + { year: '下一步', title: '自主制片', text: 'YMB 自制与联合制片项目,从剧本到成片全程支持。' }, + { year: '下一步', title: '算力市场', text: '在 YMB 生态内直接出售或调配渲染与 AI 算力。' }, + ], + ctaTitle: '和 YMB 一起打造下一个爆款。', + ctaText: '无论你是创作者、制片方还是算力伙伴,YMB 都有你的位置。', + ctaButton: '开始探索', + }, +}; diff --git a/src/i18n/index.js b/src/i18n/index.js index d35f4d2..3f35632 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,7 +1,7 @@ // Author: fruit.com.cn export const locales = { - id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', voiceSearch: 'Cari dengan suara', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], homeCategories: ['Semua', 'Podcast', 'Musik', 'Game', 'Live', 'Travel', 'Baru diupload', 'Temukan video baru'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' }, - en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', voiceSearch: 'Search with your voice', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], homeCategories: ['All', 'Podcasts', 'Music', 'Gaming', 'Live', 'Travel', 'Recently uploaded', 'New to you'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' }, - zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', voiceSearch: '通过语音搜索内容', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], homeCategories: ['全部', '播客', '音乐', '游戏', '直播', '旅游', '最近上传', '发现新视频'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' } + id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' }, + en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' }, + zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' } }; export function getLocale(code) { return locales[code] || locales.id; } diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index 74f782d..9cb0be1 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -1,41 +1,85 @@ // Author: fruit.com.cn -import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react'; -import { useState } from 'react'; -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { Bell, Compass, History, Home, Info, ListVideo, Menu, Plus, Search, Upload, UserRound, X } from 'lucide-react'; +import { useEffect, useState, useRef } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAppStore } from '../app/store'; import { LanguageSwitcher } from '../components/LanguageSwitcher'; +import { UploadVideoDialog } from '../components/UploadVideoDialog'; export function AppShell() { const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore(); const navigate = useNavigate(); + const location = useLocation(); const [searchOpen, setSearchOpen] = useState(false); - const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']]; - const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']]; - const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']]; + const [createOpen, setCreateOpen] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); + const searchInputRef = useRef(null); + const createRef = useRef(null); + const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [ListVideo, locale.subscriptions, '/subscriptions']]; + const libraryItems = [[History, locale.history, '/history']]; + const aboutItems = [[Info, locale.about, '/about']]; + const immersive = location.pathname === '/shorts'; + useEffect(() => { + if (!createOpen) return undefined; + const closeMenu = (event) => { + if (!createRef.current?.contains(event.target)) setCreateOpen(false); + }; + document.addEventListener('mousedown', closeMenu); + return () => document.removeEventListener('mousedown', closeMenu); + }, [createOpen]); const submitSearch = (event) => { event.preventDefault(); - navigate(`/search?q=${encodeURIComponent(query)}`); + setSearchOpen(false); + const keyword = query.trim(); + navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search'); + }; + const navClass = (to) => { + const [path, search = ''] = to.split('?'); + if (location.pathname !== path) return ''; + const current = new URLSearchParams(location.search); + return [...new URLSearchParams(search)].every(([key, value]) => current.get(key) === value) ? 'active' : ''; }; return ( <>+++ + + {submitted ? ( +{locale.uploadVideo}
++ ++ ) : ( + <> + + + + > + )} ++ {locale.uploadSuccessTitle}
+{locale.uploadSuccessText}
+{locale.uploadDone} +- @@ -43,7 +87,7 @@ export function AppShell() { -setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button"> - - -- - --
+ ++setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button"> + + ++ ++
+ +-setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"> +setSearchOpen(!searchOpen)} aria-expanded={searchOpen} aria-label={locale.search} type="button"> - - {locale.create} + ++setCreateOpen((open) => !open)}> + + {createOpen && ( +{locale.create} + ++ )} +{ setCreateOpen(false); setUploadOpen(true); }}> + +{locale.uploadVideo} + N + {sidebarOpen && {!immersive && } setSidebarOpen(false)} aria-label="Close" type="button"> } +setUploadOpen(false)} /> > ); } diff --git a/src/lib/format.js b/src/lib/format.js new file mode 100644 index 0000000..e54f522 --- /dev/null +++ b/src/lib/format.js @@ -0,0 +1,39 @@ +// Author: fruit.com.cn +const UNITS = { + id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']], + en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']], + zh: [[1e8, '亿'], [1e4, '万']], +}; + +const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' }; +const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' }; + +function formatDecimal(value, code) { + const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, ''); + return code === 'id' ? rounded.replace('.', ',') : rounded; +} + +export function formatCount(value, code = 'id') { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return value == null ? '' : String(value); + for (const [size, unit] of UNITS[code] || UNITS.id) { + if (numeric >= size) { + const separator = code === 'id' ? ' ' : ''; + return `${formatDecimal(numeric / size, code)}${separator}${unit}`; + } + } + return String(Math.round(numeric)); +} + +export function formatViews(value, code = 'id') { + if (value === null || value === undefined || value === '') return ''; + if (typeof value === 'string' && !Number.isFinite(Number(value))) return value; + const count = formatCount(value, code); + if (!count) return ''; + const label = VIEW_LABEL[code] || VIEW_LABEL.id; + return code === 'zh' ? `${count}${label}` : `${count} ${label}`; +} + +export function daysAgoLabel(code = 'id') { + return DAYS_AGO[code] || DAYS_AGO.id; +} diff --git a/src/pages/AboutPage.jsx b/src/pages/AboutPage.jsx new file mode 100644 index 0000000..f97dc1c --- /dev/null +++ b/src/pages/AboutPage.jsx @@ -0,0 +1,100 @@ +// Author: fruit.com.cn +import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { useAppStore } from '../app/store'; +import { aboutContent } from '../i18n/about'; + +const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG'; + +const PILLARS = [ + { key: 'join', icon: Users }, + { key: 'produce', icon: Clapperboard }, + { key: 'compute', icon: Cpu }, +]; + +export function AboutPage() { + const { locale } = useAppStore(); + const copy = aboutContent[locale.code] || aboutContent.en; + + return ( + ++ ); +} diff --git a/src/pages/HistoryPage.jsx b/src/pages/HistoryPage.jsx new file mode 100644 index 0000000..d57782d --- /dev/null +++ b/src/pages/HistoryPage.jsx @@ -0,0 +1,71 @@ +// Author: fruit.com.cn +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAppStore } from '../app/store'; +import { appApi } from '../services/appApi'; +import { normalizePage, normalizeProgress } from '../services/normalizers'; +import { HistoryGridSkeleton } from '../components/Skeletons'; + +function episodeLabel(record, localeCode) { + if (!record.episodeNumber) return ''; + return localeCode === 'zh' ? `第${record.episodeNumber}集` : `Episode ${record.episodeNumber}`; +} + +export function HistoryPage() { + const { locale } = useAppStore(); + const [records, setRecords] = useState(null); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setError(''); + try { + const payload = await appApi.watchRecent(1, 20); + setRecords(normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code))); + } catch (loadError) { + setError(loadError.message || 'Request failed'); + setRecords([]); + } + }, [locale.code]); + + useEffect(() => { load(); }, [load]); + + const clearAll = async () => { + if (!window.confirm(`${locale.clearHistory}?`)) return; + try { + await appApi.clearWatchHistory(); + setRecords([]); + } catch (clearError) { + setError(clearError.message || 'Request failed'); + } + }; + + return ( ++ + ++ {copy.kicker} ++ +{copy.heroTitle}
+{copy.heroText}
+++{copy.explore} + {copy.watchShorts} + + {copy.downloadLabel} + ++{copy.values.map((value) => {value})}++ + +{copy.whoTitle}
+{copy.whoText}
++ + +{copy.pillarsTitle}
++ {PILLARS.map(({ key, icon: Icon }) => ( +++ + ))} ++ {copy[`${key}Title`]}
+{copy[`${key}Text`]}
++ + +{copy.roadmapTitle}
++ {copy.roadmap.map((step, index) => ( +
+- + {String(index + 1).padStart(2, '0')} +
+ ))} ++ {step.year} · {step.title} ++{step.text}
++ +++ {copy.ctaButton}{copy.ctaTitle}
+{copy.ctaText}
++ + + ); +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index e57c51f..2221e7c 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,74 +1,113 @@ // Author: fruit.com.cn -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { MediaCard } from '../components/MediaCard'; -import { SectionHeading } from '../components/SectionHeading'; +import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../components/Skeletons'; import { videos } from '../data/catalog'; import { useAppStore } from '../app/store'; import { searchCatalog } from '../services/catalogService'; import { appApi } from '../services/appApi'; -import { normalizeDrama, uniqueDramas } from '../services/normalizers'; +import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; import { useApi } from '../hooks/useApi'; const SHORTS_LIMIT = 5; -const FIRST_SHELF_LIMIT = 5; -const ALL_GENRES_INDEX = 0; +const FIRST_SHELF_LIMIT = 6; +const ALL_CATEGORY_ID = 'all'; +const FALLBACK_GENRE_PREFIX = 'genre-'; -function normalizeHomeFeed(payload, localeCode) { - const sections = payload?.sections || []; - const dramas = sections.flatMap((section) => section.dramas || []); - return uniqueDramas(dramas.map((item) => normalizeDrama(item, localeCode))); +function readTitle(locales, localeCode) { + return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || ''; } -function filterByGenre(items, genre, localeGenres) { - if (!genre || genre === localeGenres[ALL_GENRES_INDEX]) return items; - const filtered = items.filter((item) => item.genre === genre); - return filtered.length ? filtered : items; +function normalizeHomeItems(payload, localeCode) { + const sections = payload?.sections || []; + return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode))); +} + +function normalizeCategoryItems(payload, localeCode) { + return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode))); +} + +function normalizeCategories(payload, localeCode) { + return (Array.isArray(payload) ? payload : []) + .map((item) => ({ + id: String(item.id ?? ''), + label: readTitle(item.locales, localeCode) || item.code || '', + })) + .filter((item) => item.id && item.label); +} + +function fallbackGenreIndex(categoryId) { + const matched = /^genre-(\d+)$/.exec(categoryId); + return matched ? Number(matched[1]) : 0; } export function HomePage() { const { locale } = useAppStore(); - const categoryItems = locale.homeCategories || locale.genres; - const [genre, setGenre] = useState(categoryItems[ALL_GENRES_INDEX]); - const api = useApi( - () => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)), + const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID); + const homeApi = useApi( + () => appApi.homeFeed().then((payload) => normalizeHomeItems(payload, locale.code)), [locale.code], ); - const remoteItems = api.data || []; - const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre)); - const items = useMemo( - () => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, categoryItems), - [categoryItems, fallbackItems, genre, remoteItems], + const categoriesApi = useApi( + () => appApi.categories().then((payload) => normalizeCategories(payload, locale.code)), + [locale.code], ); - const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT); + const categoryApi = useApi( + () => (activeCategory === ALL_CATEGORY_ID + ? Promise.resolve(null) + : appApi.categoryDramas(activeCategory).then((payload) => normalizeCategoryItems(payload, locale.code))), + [activeCategory, locale.code], + ); + const homeItems = homeApi.data || []; + const serverCategories = categoriesApi.data || []; + const fallbackCategories = useMemo( + () => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })), + [locale.genres], + ); + const categories = useMemo(() => { + const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] }; + if (serverCategories.length) return [allCategory, ...serverCategories]; + return categoriesApi.loading ? [allCategory] : [allCategory, ...fallbackCategories]; + }, [categoriesApi.loading, fallbackCategories, locale.genres, serverCategories]); + const fallbackItems = useMemo(() => { + const genreIndex = fallbackGenreIndex(activeCategory); + return searchCatalog('', locale.genres[genreIndex], genreIndex); + }, [activeCategory, locale.genres]); + const isAllCategory = activeCategory === ALL_CATEGORY_ID; + const loading = isAllCategory ? homeApi.loading : categoryApi.loading; + const error = isAllCategory ? homeApi.error : categoryApi.error; + const remoteItems = isAllCategory ? homeItems : (categoryApi.data || []); + const items = error ? fallbackItems : remoteItems; + const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT); const firstShelf = items.slice(0, FIRST_SHELF_LIMIT); const secondShelf = items.slice(FIRST_SHELF_LIMIT); - useEffect(() => { - setGenre(categoryItems[ALL_GENRES_INDEX]); - }, [categoryItems, locale.code]); - return ( <> -++ {records === null &&{locale.history}
+ {!!records?.length &&{locale.clearHistory} } +} + {error && {error}} + {records && !records.length && !error &&{locale.historyEmpty}} + {!!records?.length && ( ++ {records.map((record) => { + const percent = Math.round(record.fraction * 100); + const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · '); + return ( ++ )} ++ + + ); + })} ++ {record.durationMs > 0 && } + + {record.title} + {meta && {meta}} +
- {secondShelf.length > 0 &&- {categoryItems.map((item) => ( + {categoriesApi.loading && !serverCategories.length ?-: ( + + {categories.map((category) => (- {api.loading &&setGenre(item)} + aria-selected={activeCategory === category.id} + onClick={() => setActiveCategory(category.id)} > - {item} + {category.label} ))}{locale.loading}} - {api.error &&{locale.apiFallback}} -- {firstShelf.map((video) =>+ )} +)} - + {error && {locale.apiFallback}} + {!loading && !error && !items.length &&{locale.empty}} + {loading && !items.length ?: ( + + {firstShelf.map((video) =>+ )})} + @@ -76,9 +115,11 @@ export function HomePage() { ▶{locale.shortTitle}
{locale.explore} - {shortsItems.map((video) =>+ {homeApi.loading && !homeItems.length ?)} - : ( + + {shortsItems.map((video) =>+ )})} + diff --git a/src/pages/ListingPage.jsx b/src/pages/ListingPage.jsx index b65eb59..abff062 100644 --- a/src/pages/ListingPage.jsx +++ b/src/pages/ListingPage.jsx @@ -1,10 +1,27 @@ // Author: fruit.com.cn import { MediaCard } from '../components/MediaCard'; import { SectionHeading } from '../components/SectionHeading'; +import { VideoGridSkeleton } from '../components/Skeletons'; import { videos } from '../data/catalog'; import { useAppStore } from '../app/store'; import { appApi } from '../services/appApi'; import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; import { useApi } from '../hooks/useApi'; -export function ListingPage({ type }) { const { locale } = useAppStore(); const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data?.length ? api.data : videos; return <> {api.loading ? locale.loading : api.error ? locale.apiFallback : ''}{items.map((video) =>>; } +export function ListingPage({ type }) { + const { locale } = useAppStore(); + const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library; + const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); + const items = api.data?.length ? api.data : videos; + return ( + <> +)} + {api.loading ? : ( + <> + {api.error && {locale.apiFallback}} +{items.map((video) =>+ > + )} + > + ); +} diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx index f36a4f4..8a70524 100644 --- a/src/pages/SearchPage.jsx +++ b/src/pages/SearchPage.jsx @@ -1,11 +1,46 @@ // Author: fruit.com.cn +import { Search as SearchIcon } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { MediaCard } from '../components/MediaCard'; import { SectionHeading } from '../components/SectionHeading'; +import { VideoGridSkeleton } from '../components/Skeletons'; import { useAppStore } from '../app/store'; -import { searchCatalog } from '../services/catalogService'; import { appApi } from '../services/appApi'; import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; import { useApi } from '../hooks/useApi'; -export function SearchPage() { const { locale } = useAppStore(); const [params] = useSearchParams(); const query = params.get('q') || ''; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const source = api.data?.length ? api.data : searchCatalog('', '', 0); const normalizedQuery = query.toLowerCase(); const results = source.filter((video) => `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalizedQuery)); return <>)} {api.loading ? locale.loading : api.error ? locale.apiFallback : ''}{results.map((video) =>>; } +async function loadSearchResults(keyword, localeCode) { + if (keyword) { + const payload = await appApi.search(keyword); + return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)); + } + const payload = await appApi.homeFeed(); + const items = payload?.sections?.flatMap((section) => section.dramas || []) || []; + return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode))); +} + +export function SearchPage() { + const { locale } = useAppStore(); + const [params] = useSearchParams(); + const query = (params.get('q') || '').trim(); + const api = useApi(() => loadSearchResults(query, locale.code), [query, locale.code]); + const results = api.data || []; + return ( + <> +)} + {api.loading ? : ( + <> + {api.error && {locale.apiFallback}} + {!api.error && query && results.length === 0 ? ( +++ ) : ( ++ {locale.noResults} “{query}” + {results.map((video) =>+ )} + > + )} + > + ); +} diff --git a/src/pages/ShortsFeedPage.jsx b/src/pages/ShortsFeedPage.jsx index dcd811e..e7e0577 100644 --- a/src/pages/ShortsFeedPage.jsx +++ b/src/pages/ShortsFeedPage.jsx @@ -1,13 +1,237 @@ // Author: fruit.com.cn -import { useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Play } from 'lucide-react'; -import { videos } from '../data/catalog'; -import { appApi } from '../services/appApi'; -import { normalizePage, normalizeDrama, selectPlaybackUrl, uniqueDramas } from '../services/normalizers'; -import { useApi } from '../hooks/useApi'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; import { useAppStore } from '../app/store'; +import { appApi } from '../services/appApi'; +import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers'; import { VideoPlayer } from '../components/player/VideoPlayer'; +import { CommentsSheet } from '../components/CommentsSheet'; +import { ShortsSkeleton } from '../components/Skeletons'; +import { formatCount } from '../lib/format'; +import { reportWatchProgress } from '../services/watchProgress'; +import { useFollowedChannels } from '../hooks/useFollowedChannels'; -function FeedItem({ video }) { const ref = useRef(null); const [active, setActive] = useState(false); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); useEffect(() => { const element = ref.current; const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { threshold: 0.7 }); observer.observe(element); return () => observer.disconnect(); }, []); return)} ; } -export function ShortsFeedPage() { const { locale } = useAppStore(); const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data || []; return {src ?: }
YMB SHORTS{video.title}
{video.channel}
{video.watch || 'Watch drama'} ; } +const PAGE_SIZE = 6; +const PRELOAD_THRESHOLD = 2; + +function ShortSlide({ video, active, locale, followed, onToggleFollow, onOpenComments, onShare }) { + const src = video.preview?.playUrl; + const [liked, setLiked] = useState(false); + const [likeCount, setLikeCount] = useState(video.likeCount); + const [saved, setSaved] = useState(false); + const [saveCount, setSaveCount] = useState(video.favoriteCount); + + const toggleLike = async () => { + try { + const result = await appApi.toggleLike(video.preview?.id ? 'EPISODE' : 'DRAMA', video.preview?.id || video.id); + setLiked(Boolean(result?.liked)); + setLikeCount(Number(result?.count) || 0); + } catch (error) { + console.error('YMB LIKE FAILED', error); + } + }; + + const toggleSave = async () => { + try { + const next = await appApi.toggleFavorite(video.id); + setSaved(Boolean(next)); + setSaveCount((count) => Math.max(0, count + (next ? 1 : -1))); + } catch (error) { + console.error('YMB SAVE FAILED', error); + } + }; + + return ( + {api.loading &&YMB SHORTS{locale.shortTitle}
{locale.swipeHint || 'Swipe up to discover the next story'}
Loading real shorts…}{api.error &&{api.error}}{!api.loading && !items.length &&{locale.empty}}{items.map((video) =>)} + + ); +} + +export function ShortsFeedPage() { + const { locale, sidebarOpen } = useAppStore(); + const [params] = useSearchParams(); + const startId = params.get('start'); + const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(''); + const [status, setStatus] = useState({ loading: true, error: '', hasMore: true }); + const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels(); + const [commentsVideo, setCommentsVideo] = useState(null); + const [toast, setToast] = useState(''); + const feedRef = useRef(null); + const cursorRef = useRef(null); + const loadingRef = useRef(false); + const requestIdRef = useRef(0); + const toastTimer = useRef(null); + + const loadPage = useCallback(async (reset) => { + if (loadingRef.current && !reset) return; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + loadingRef.current = true; + setStatus((prev) => ({ ...prev, loading: true, error: '' })); + try { + const payload = await appApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE }); + if (requestId !== requestIdRef.current) return; + const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code))); + cursorRef.current = payload?.nextCursor ?? null; + setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list]))); + setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) }); + } catch (error) { + if (requestId !== requestIdRef.current) return; + setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' })); + } finally { + if (requestId === requestIdRef.current) loadingRef.current = false; + } + }, [locale.code]); + + useEffect(() => { + cursorRef.current = null; + setItems([]); + setActiveId(''); + setStatus({ loading: true, error: '', hasMore: true }); + loadPage(true); + }, [loadPage]); + + useEffect(() => { + if (!items.length) return; + if (items.some((item) => item.id === activeId)) return; + const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1; + const nextIndex = startIndex >= 0 ? startIndex : 0; + const node = feedRef.current; + if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight; + setActiveId(items[nextIndex].id); + }, [activeId, items, startId]); + + useEffect(() => { + const node = feedRef.current; + if (!node) return undefined; + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) setActiveId(entry.target.dataset.id); + }); + }, { root: node, threshold: 0.7 }); + node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide)); + return () => observer.disconnect(); + }, [items.length]); + + useEffect(() => { + if (!status.hasMore || status.loading || !activeId) return; + const index = items.findIndex((item) => item.id === activeId); + if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false); + }, [activeId, items, loadPage, status.hasMore, status.loading]); + + useEffect(() => () => { + if (toastTimer.current) window.clearTimeout(toastTimer.current); + }, []); + + const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId)); + const move = (delta) => { + const node = feedRef.current; + if (!node) return; + const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta)); + if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' }); + }; + + const showToast = (message) => { + setToast(message); + if (toastTimer.current) window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(''), 2000); + }; + + const shareVideo = async (video) => { + const url = `${window.location.origin}/watch/${video.id}`; + if (navigator.share) { + try { + await navigator.share({ title: video.title, url }); + return; + } catch (error) { + if (error?.name === 'AbortError') return; + } + } + try { + await navigator.clipboard.writeText(url); + showToast(locale.linkCopied); + } catch { + showToast(url); + } + }; + + useEffect(() => { + const onKeyDown = (event) => { + if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } + if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [activeIndex, items.length]); + + return ( + <> ++++++ {video.channel?.[0] || 'Y'} + {video.channel} ++{followed ? locale.subscribed : locale.subscribe} +{video.title}
++++++ {src + ?+reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} /> + : } +{video.title}
+++ ++ {formatCount(saveCount, locale.code)} + + ++ {formatCount(likeCount, locale.code)} + + ++ {formatCount(video.commentCount, locale.code)} + + ++ {locale.share} + + {status.loading && !items.length && + {commentsVideo &&} + {status.error && {status.error}} + {!status.loading && !status.error && !items.length &&{locale.empty}} ++ {items.map((video) => ( ++ {items.length > 1 && ( +toggleFollow(video.channel)} + onOpenComments={() => setCommentsVideo(video)} + onShare={() => shareVideo(video)} + /> + ))} + ++ )} +move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑ +move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓ +setCommentsVideo(null)} />} + {toast && {toast}} + > + ); +} diff --git a/src/pages/WatchPage.jsx b/src/pages/WatchPage.jsx index ad95f72..4d242d2 100644 --- a/src/pages/WatchPage.jsx +++ b/src/pages/WatchPage.jsx @@ -1,40 +1,235 @@ // Author: fruit.com.cn import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import { Bookmark, Lock, MessageCircle, Share2, ThumbsUp } from 'lucide-react'; import { useAppStore } from '../app/store'; import { appApi } from '../services/appApi'; -import { normalizeDrama, normalizePage, selectPlaybackUrl, uniqueDramas } from '../services/normalizers'; +import { normalizeDrama, selectPlaybackUrl } from '../services/normalizers'; import { useApi } from '../hooks/useApi'; +import { useFollowedChannels } from '../hooks/useFollowedChannels'; import { VideoPlayer } from '../components/player/VideoPlayer'; +import { CommentsSheet } from '../components/CommentsSheet'; +import { WatchPageSkeleton } from '../components/Skeletons'; +import { formatCount, formatViews } from '../lib/format'; +import { reportWatchProgress } from '../services/watchProgress'; -function SwipeVideo({ video, active }) { - const ref = useRef(null); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); const image = video.image; - useEffect(() => { const node = ref.current; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) node.classList.add('visible'); else node.classList.remove('visible'); }, { threshold: 0.7 }); observer.observe(node); return () => observer.disconnect(); }, []); - return; +function episodeLabel(episode, index, code) { + const number = episode?.number ?? index + 1; + return code === 'zh' ? `第${number}集` : `Episode ${number}`; } -function normalizeFeedItems(payload, localeCode) { - return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode))); -} +function WatchContent({ video, locale }) { + const episodes = video.episodes?.length + ? (video.preview && !video.episodes.some((item) => item.id === video.preview.id) ? [video.preview, ...video.episodes] : video.episodes) + : (video.preview ? [video.preview] : []); + const [episodeId, setEpisodeId] = useState(video.preview?.id || episodes[0]?.id); + const episodeIndex = Math.max(0, episodes.findIndex((item) => item.id === episodeId)); + const currentEpisode = episodes[episodeIndex] || episodes[0] || null; + const nextEpisode = episodes[episodeIndex + 1]; + const previousEpisode = episodes[episodeIndex - 1]; -async function loadWatchFeed(localeCode) { - try { - const page = normalizeFeedItems(await appApi.dramas(), localeCode); - if (page.length) return page; - } catch (error) { - const fallback = await appApi.homeFeed(); - const items = fallback?.sections?.flatMap((section) => section.dramas || []) || []; - return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode))); - } - const fallback = await appApi.homeFeed(); - const items = fallback?.sections?.flatMap((section) => section.dramas || []) || []; - return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode))); + const playback = useApi(() => currentEpisode && !currentEpisode.locked ? appApi.episodePlay(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id, currentEpisode?.locked]); + const stats = useApi(() => currentEpisode?.id ? appApi.episodeStats(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id]); + const src = currentEpisode?.locked ? '' : selectPlaybackUrl(playback.data, currentEpisode?.playUrl); + const views = formatViews(video.views, locale.code); + const tag = video.genre ? `#${video.genre.replace(/\s+/g, '')}` : ''; + const { channels, toggle } = useFollowedChannels(); + const followed = channels.includes(video.channel); + const [liked, setLiked] = useState(false); + const [likeCount, setLikeCount] = useState(Number(currentEpisode?.likeCount) || 0); + const [saved, setSaved] = useState(false); + const [saveCount, setSaveCount] = useState(Number(video.favoriteCount) || 0); + const [commentsOpen, setCommentsOpen] = useState(false); + const [toast, setToast] = useState(''); + const toastTimer = useRef(null); + const swipeStart = useRef(null); + const playerRef = useRef(null); + + useEffect(() => { + const data = stats.data; + if (!data) return; + setLiked(Boolean(data.liked)); + setLikeCount(Number(data.likeCount) || 0); + setSaved(Boolean(data.favorited)); + setSaveCount(Number(data.favoriteCount) || 0); + }, [stats.data]); + + useEffect(() => () => { + if (toastTimer.current) window.clearTimeout(toastTimer.current); + }, []); + + const changeEpisode = (episode, scroll = false) => { + if (!episode || episode.id === currentEpisode?.id) return; + setEpisodeId(episode.id); + if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + const goNext = () => changeEpisode(nextEpisode); + const goPrevious = () => changeEpisode(previousEpisode); + + useEffect(() => { + const node = playerRef.current; + if (!node) return undefined; + let last = 0; + const onWheel = (event) => { + if (Math.abs(event.deltaY) < 20) return; + const forward = event.deltaY > 0; + if (forward ? !nextEpisode : !previousEpisode) return; + event.preventDefault(); + const now = Date.now(); + if (now - last < 700) return; + last = now; + if (forward) goNext(); else goPrevious(); + }; + node.addEventListener('wheel', onWheel, { passive: false }); + return () => node.removeEventListener('wheel', onWheel); + }, [nextEpisode, previousEpisode]); + + if (!currentEpisode) return {src ?: }{playback.loading ? 'Loading video…' : playback.error || 'Preview unavailable'}
YMB SHORTS{video.title}
{video.channel} · {video.views}
{locale.playbackUnavailable}; + const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0; + + const onTouchStart = (event) => { + const touch = event.touches[0]; + swipeStart.current = { x: touch.clientX, y: touch.clientY }; + }; + + const onTouchEnd = (event) => { + const start = swipeStart.current; + swipeStart.current = null; + if (!start) return; + const touch = event.changedTouches[0]; + const dx = start.x - touch.clientX; + const dy = start.y - touch.clientY; + if (Math.abs(dy) < 50 || Math.abs(dx) > Math.abs(dy)) return; + if (dy > 0) goNext(); else goPrevious(); + }; + + const showToast = (message) => { + setToast(message); + if (toastTimer.current) window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(''), 2000); + }; + + const toggleLike = async () => { + try { + const result = await appApi.toggleLike('EPISODE', currentEpisode.id); + setLiked(Boolean(result?.liked)); + setLikeCount(Number(result?.count) || 0); + } catch (error) { + console.error('YMB LIKE FAILED', error); + } + }; + + const toggleSave = async () => { + try { + const next = await appApi.toggleFavorite(video.id); + setSaved(Boolean(next)); + setSaveCount((count) => Math.max(0, count + (next ? 1 : -1))); + } catch (error) { + console.error('YMB SAVE FAILED', error); + } + }; + + const share = async () => { + const url = `${window.location.origin}/watch/${video.id}`; + if (navigator.share) { + try { + await navigator.share({ title: video.title, url }); + return; + } catch (error) { + if (error?.name === 'AbortError') return; + } + } + try { + await navigator.clipboard.writeText(url); + showToast(locale.linkCopied); + } catch { + showToast(url); + } + }; + + const label = episodeLabel(currentEpisode, episodeIndex, locale.code); + const commentVideo = { ...video, preview: currentEpisode }; + + return ( +++ ); } export function WatchPage() { - const { id } = useParams(); const navigate = useNavigate(); const { locale } = useAppStore(); const [activeId, setActiveId] = useState(id); const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); const feed = useApi(() => loadWatchFeed(locale.code), [locale.code]); const items = feed.data || []; const ordered = uniqueDramas([current.data, ...items.filter((item) => item.id !== String(id))].filter(Boolean)); - const activeIndex = Math.max(0, ordered.findIndex((item) => String(item.id) === String(activeId))); const feedRef = useRef(null); const touchStart = useRef(0); const scrollToIndex = (index) => { const node = feedRef.current; if (!node) return; node.scrollTo({ top: index * node.clientHeight, behavior: 'smooth' }); }; const move = (delta) => { if (ordered.length < 2) return; scrollToIndex(Math.min(ordered.length - 1, Math.max(0, activeIndex + delta))); }; - useEffect(() => { const node = feedRef.current; if (!node) return undefined; const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { if (entry.isIntersecting) { const nextId = entry.target.dataset.id; setActiveId(nextId); if (nextId !== id) window.history.replaceState(null, '', `/watch/${nextId}`); } }), { root: node, threshold: 0.75 }); node.querySelectorAll('[data-id]').forEach((item) => observer.observe(item)); const keydown = (event) => { if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } }; window.addEventListener('keydown', keydown); return () => { observer.disconnect(); window.removeEventListener('keydown', keydown); }; }, [ordered.length, id, activeIndex]); - const hasNext = feed.loading || activeIndex < ordered.length - 1; const hasPrevious = activeIndex > 0; - return++ + {episodes.length > 0 && ( + + )} + + {commentsOpen &&+ {src + ?+reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> + : } + {locale.episodeSwipeHint} +{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error || locale.playbackUnavailable}
++↑ +↓ +{video.title}
+{label}
++++ {video.channel?.[0] || 'Y'} ++{video.channel}+toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe} ++++ {formatCount(likeCount, locale.code)} + {formatCount(saveCount, locale.code)} setCommentsOpen(true)} type="button"> +{formatCount(commentCount, locale.code)} + {locale.share} + {views}{tag ? ` · ${tag}` : ''} + {video.description &&+{video.description}
} +setCommentsOpen(false)} />} + {toast && {toast}} +; + const { id } = useParams(); + const { locale } = useAppStore(); + const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); + const video = current.data; + + useEffect(() => { + window.scrollTo({ top: 0 }); + const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0); + return () => window.clearTimeout(timer); + }, [id]); + + if (!video && current.loading) return{(current.loading || feed.loading) && navigate(-1)}>← {locale.swipeHint || 'Swipe up to discover the next story'}
{locale.loading}}{feed.error &&{feed.error}}{ touchStart.current = event.touches[0].clientY; }} onTouchEnd={(event) => { const distance = touchStart.current - event.changedTouches[0].clientY; if (Math.abs(distance) > 45) move(distance > 0 ? 1 : -1); }} onWheel={(event) => { if (Math.abs(event.deltaY) > 20) move(event.deltaY > 0 ? 1 : -1); }}>{ordered.map((video) =>)}move(-1)} disabled={!hasPrevious} aria-label="Previous video">↑ move(1)} disabled={!hasNext} aria-label="Next video">↓ ; + if (!video) return {current.error || locale.empty}; + + return; } diff --git a/src/services/appApi.js b/src/services/appApi.js index 3da16c6..9e04b8b 100644 --- a/src/services/appApi.js +++ b/src/services/appApi.js @@ -3,9 +3,29 @@ import { request } from './httpClient'; export const appApi = { homeFeed: () => request('/app/home/feed'), + feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}) => { + const params = new URLSearchParams({ size: String(size) }); + if (types) params.set('types', types); + if (categoryId) params.set('categoryId', String(categoryId)); + if (cursor !== undefined && cursor !== null && cursor !== '') params.set('cursor', String(cursor)); + return request(`/app/feed?${params.toString()}`); + }, categories: () => request('/app/categories'), + categoryDramas: (id, pageIndex = 1, pageSize = 20) => request(`/app/categories/${encodeURIComponent(id)}/dramas?pageIndex=${pageIndex}&pageSize=${pageSize}`), dramas: (body = {}) => request('/app/dramas/page', { method: 'POST', body: JSON.stringify({ pageIndex: 1, pageSize: 20, categoryIds: [], classificationIds: [], tagIds: [], ...body }) }), drama: (id) => request(`/app/dramas/${encodeURIComponent(id)}`), episodePlay: (id) => request(`/app/episodes/${encodeURIComponent(id)}/play`), + episodeStats: (id) => request(`/app/episodes/${encodeURIComponent(id)}/stats`), + search: (keyword) => request(`/app/search?keyword=${encodeURIComponent(keyword)}&pageIndex=1&pageSize=48`), progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }), + watchRecent: (pageIndex = 1, pageSize = 20) => request(`/app/watch/recent?pageIndex=${pageIndex}&pageSize=${pageSize}`), + clearWatchHistory: () => request('/app/watch/progress', { method: 'DELETE' }), + toggleLike: (targetType, targetId) => request('/app/likes/toggle', { method: 'POST', body: JSON.stringify({ targetType, targetId }) }), + toggleFavorite: (dramaId) => request('/app/favorites/toggle', { method: 'POST', body: JSON.stringify({ dramaId }) }), + comments: ({ dramaId, episodeId, size = 30 }) => { + const params = new URLSearchParams({ dramaId: String(dramaId), size: String(size) }); + if (episodeId) params.set('episodeId', String(episodeId)); + return request(`/app/comments?${params.toString()}`); + }, + publishComment: ({ dramaId, episodeId, content }) => request('/app/comments', { method: 'POST', body: JSON.stringify({ dramaId, ...(episodeId ? { episodeId } : {}), content }) }), }; diff --git a/src/services/device.js b/src/services/device.js new file mode 100644 index 0000000..512df21 --- /dev/null +++ b/src/services/device.js @@ -0,0 +1,21 @@ +// Author: fruit.com.cn +const DEVICE_KEY = 'ymb.device.id'; + +function createDeviceId() { + const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; + return `d-${uuid}`; +} + +let cachedDeviceId = ''; + +export function getDeviceId() { + if (cachedDeviceId) return cachedDeviceId; + try { + const stored = window.localStorage.getItem(DEVICE_KEY); + cachedDeviceId = stored || createDeviceId(); + if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId); + } catch { + cachedDeviceId = createDeviceId(); + } + return cachedDeviceId; +} diff --git a/src/services/httpClient.js b/src/services/httpClient.js index ade8de3..ab6a8c6 100644 --- a/src/services/httpClient.js +++ b/src/services/httpClient.js @@ -1,4 +1,6 @@ // Author: fruit.com.cn +import { getDeviceId } from './device'; + const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''); const REQUEST_TIMEOUT_MS = 12000; @@ -8,7 +10,7 @@ export async function request(path, options = {}) { try { const response = await fetch(`${API_BASE_URL}${path}`, { ...options, - headers: { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) }, + headers: { Accept: 'application/json', 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) }, signal: controller.signal, }); const payload = await response.json().catch(() => ({})); diff --git a/src/services/normalizers.js b/src/services/normalizers.js index c8b00aa..5145bde 100644 --- a/src/services/normalizers.js +++ b/src/services/normalizers.js @@ -9,7 +9,7 @@ export function normalizeDrama(item, lang = 'id') { const id = String(item.id ?? item.dramaId ?? item.code ?? ''); const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []); const preview = item.preview || episodes[0] || null; - return { id, title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null }; + return { id, title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', likeCount: Number(item.likeCount) || 0, favoriteCount: Number(item.favoriteCount) || 0, commentCount: Number(item.commentCount) || 0, description: getText(item.synopsisLocales, lang) || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null }; } export function normalizePage(payload) { @@ -22,3 +22,22 @@ export function resolvePlaybackUrl(value) { return resolveMedia(value); } export function uniqueDramas(items) { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); } export function selectPlaybackUrl(playback, fallback = '') { const tracks = playback?.tracks || []; const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0]; return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback); } + +export function normalizeProgress(item, lang = 'id') { + const durationMs = Number(item.durationMs) || 0; + const positionMs = Number(item.positionMs) || 0; + return { + id: String(item.dramaId ?? ''), + episodeId: String(item.episodeId ?? ''), + title: getText(item.titleLocales, lang) || item.dramaTitle || 'YMB Drama', + image: resolveMedia(item.dramaCoverLandscape || item.dramaCover || ''), + episodeNumber: item.episodeNumber == null ? null : Number(item.episodeNumber), + totalEpisodes: item.totalEpisodes == null ? null : Number(item.totalEpisodes), + positionMs, + durationMs, + fraction: durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0, + viewCount: item.viewCount, + finished: Boolean(item.finished), + updatedAt: item.updatedAt || '', + }; +} diff --git a/src/services/watchProgress.js b/src/services/watchProgress.js new file mode 100644 index 0000000..0e7cf0a --- /dev/null +++ b/src/services/watchProgress.js @@ -0,0 +1,21 @@ +// Author: fruit.com.cn +import { appApi } from './appApi'; + +const REPORT_INTERVAL_MS = 10000; +const lastReportedAt = new Map(); + +export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, source = 'web', force = false) { + const id = Number(episodeId); + if (!Number.isFinite(id) || id <= 0) return; + const now = Date.now(); + if (!force && now - (lastReportedAt.get(id) || 0) < REPORT_INTERVAL_MS) return; + lastReportedAt.set(id, now); + const positionMs = Math.max(0, Math.round((Number(positionSeconds) || 0) * 1000)); + const durationMs = Math.max(0, Math.round((Number(durationSeconds) || 0) * 1000)); + appApi.progress({ + episodeId: id, + positionMs, + ...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}), + source, + }).catch(() => {}); +} diff --git a/src/youtube-overrides.css b/src/youtube-overrides.css index bcbfedb..a65febc 100644 --- a/src/youtube-overrides.css +++ b/src/youtube-overrides.css @@ -6,103 +6,422 @@ --text: #f1f1f1; --muted: #aaa; --accent: #ff4b3e; - --sidebar: 224px; + --sidebar: 240px; + --topbar: 56px; } -body { background: var(--bg); color: var(--text); font-family: Arial, Helvetica, sans-serif; } -.topbar { height: 56px; gap: 16px; padding: 0 20px; background: #0f0f0ff2; border-bottom: 1px solid #222; box-shadow: none; } -.topbar-menu { color: var(--text); } -.topbar-menu:hover, .voice-search:hover, .notification-button:hover { background: #272727; } -.brand { min-width: 138px; height: 40px; } -.brand img { width: 104px; height: 38px; object-fit: contain; } -.search-form { height: 40px; max-width: 640px; flex: 1; margin: 0 0 0 clamp(18px, 7vw, 110px); border-radius: 22px; } -.search-form input { height: 40px; border-color: #3b3b3b; background: #121212; color: var(--text); } -.search-form button { height: 40px; background: #282828; color: var(--text); border-color: #3b3b3b; } -.voice-search { flex: none; width: 40px; height: 40px; border-radius: 50%; color: var(--text); } -.top-actions { gap: 14px; margin-left: auto; color: var(--text); } -.create-button { display: flex; align-items: center; gap: 6px; border: 0; border-radius: 22px; padding: 9px 14px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; } +body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, Helvetica, sans-serif; } + +/* 顶部栏:左中右三段,搜索框居中 */ +.topbar { position: sticky; top: 0; z-index: 30; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 560px) minmax(0, 1fr); align-items: center; column-gap: 16px; height: var(--topbar); padding: 0 16px; background: var(--bg); border-bottom: 0; box-shadow: none; } +.topbar-left { display: flex; align-items: center; gap: 16px; min-width: 0; } +.topbar-menu { width: 40px; height: 40px; border-radius: 50%; color: var(--text); cursor: pointer; } +.topbar-menu:hover, .notification-button:hover, .mobile-search:hover { background: #272727; } +.brand { display: flex; align-items: center; height: 40px; min-width: 0; } +.brand img { width: 90px; height: 30px; object-fit: contain; } +.topbar-center { display: flex; align-items: center; gap: 8px; min-width: 0; } +.search-form { display: flex; flex: 1; min-width: 0; height: 40px; max-width: none; margin: 0; border-radius: 20px; } +.search-field { position: relative; display: flex; flex: 1; min-width: 0; } +.search-form input { width: 100%; height: 40px; padding: 0 42px 0 16px; border: 1px solid #303030; border-radius: 20px 0 0 20px; background: #121212; color: var(--text); font-size: 14px; } +.search-form input::placeholder { color: #888; } +.search-form input:focus { outline: none; border-color: #1c62b9; } +.search-form button { flex: none; display: grid; place-items: center; width: 64px; height: 40px; border: 1px solid #303030; border-left: 0; border-radius: 0 20px 20px 0; background: #222; color: var(--text); cursor: pointer; } +.search-form button:hover { background: #303030; } +.search-form .search-field .search-clear { position: absolute; right: 8px; top: 50%; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: #303030; color: #ddd; transform: translateY(-50%); cursor: pointer; } +.search-form .search-field .search-clear:hover { background: #444; } +.search-empty { display: grid; place-items: center; gap: 8px; padding: 56px 0; color: #aaa; } +.search-empty b { color: #fff; font-size: 16px; font-weight: 500; } +.top-actions { display: flex; align-items: center; justify-self: end; gap: 10px; min-width: 0; color: var(--text); } +.mobile-search { display: none; } +.create-wrap { position: relative; } +.create-button { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; } .create-button:hover { background: #343434; } +.create-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; min-width: 208px; padding: 8px; border: 1px solid #303030; border-radius: 12px; background: #212121; box-shadow: 0 12px 32px #000a; } +.create-menu button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; } +.create-menu button:hover { background: #3a3a3a; } +.upload-backdrop { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: #000a; } +.upload-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; padding: 22px 24px 24px; border: 1px solid #303030; border-radius: 16px; background: #1c1c1c; box-shadow: 0 24px 64px #000c; } +.upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.upload-dialog h2 { margin: 0; color: #fff; font-size: 20px; font-weight: 600; } +.upload-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 20px; padding: 36px 20px; border: 1px dashed #3f3f3f; border-radius: 12px; background: #ffffff08; color: #c9c9c9; text-align: center; cursor: pointer; transition: border-color .18s ease, background .18s ease; } +.upload-drop:hover, .upload-drop.dragging { border-color: #7aa7ff; background: #1f6feb1a; } +.upload-drop b { max-width: 100%; color: #fff; font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } +.upload-drop small { color: #9a9a9a; font-size: 12px; } +.upload-field { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; } +.upload-field span { color: #cfcfcf; font-size: 13px; } +.upload-field input { height: 42px; padding: 0 14px; border: 1px solid #3a3a3a; border-radius: 10px; background: #121212; color: #fff; font-size: 14px; } +.upload-field input:focus { border-color: #7aa7ff; outline: none; } +.upload-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } +.upload-cancel, .upload-submit { height: 38px; padding: 0 18px; border: 0; border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; } +.upload-cancel { background: #2c2c2c; color: #eaeaea; } +.upload-cancel:hover { background: #3a3a3a; } +.upload-submit { background: #fff; color: #111; } +.upload-submit:disabled { opacity: .4; cursor: not-allowed; } +.upload-done { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 32px 8px 8px; color: #4ade80; text-align: center; } +.upload-done h3 { margin: 4px 0 0; color: #fff; font-size: 18px; font-weight: 600; } +.upload-done p { margin: 0; color: #a8a8a8; font-size: 13px; } +.upload-done .upload-submit { margin-top: 12px; } .notification-button { width: 40px; height: 40px; border-radius: 50%; } -.language-trigger { border-color: #363636; background: #1d1d1d; color: #ddd; } -.profile-chip { background: #065fd4; color: #fff; } +.language-trigger { height: 40px; padding: 0 10px; gap: 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text); font-size: 12px; font-weight: 600; } +.language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #272727; color: #fff; } +.language-trigger svg { color: #b9b9b9; } +.language-trigger .language-code { color: #fff; font-size: 12px; font-weight: 600; } +.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 14px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } +.language-flag svg { display: block; width: 100%; height: 100%; } +.language-trigger .language-chevron { color: #8a8a8a; } +.profile-chip { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #065fd4; color: #fff; } -.sidebar { top: 56px; width: var(--sidebar); padding: 12px 10px; background: var(--bg); border: 0; box-shadow: none; color: var(--text); } -.sidebar.closed { width: 72px; padding: 12px 8px; } -.sidebar nav a, .genre-link { min-height: 40px; padding: 9px 10px; border-radius: 10px; color: #ccc; gap: 18px; } -.sidebar nav a:hover, .genre-link:hover, .sidebar nav a.active { background: #272727; color: #fff; } +/* 侧边栏:展开 240px,收起 72px 图标 + 文字 */ +.sidebar { position: fixed; top: var(--topbar); bottom: 0; left: 0; z-index: 20; width: var(--sidebar); padding: 12px; overflow-y: auto; background: var(--bg); border: 0; box-shadow: none; color: var(--text); } +.sidebar::-webkit-scrollbar { width: 8px; } +.sidebar::-webkit-scrollbar-thumb { background: #4d4d4d; border-radius: 4px; } +.sidebar.closed { width: 72px; padding: 4px 4px 12px; } .sidebar-group { display: grid; gap: 2px; } -.sidebar-group-title { display: flex; align-items: center; gap: 7px; padding: 12px 10px 6px; font-size: 16px; font-weight: 700; } -.sidebar-group-title svg { color: #999; } +.sidebar a, .genre-link { display: flex; align-items: center; gap: 24px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; } +.sidebar a svg, .genre-link svg { flex: none; width: 24px; height: 24px; } +.sidebar a:hover, .genre-link:hover, .sidebar a.active { background: #272727; color: #fff; } .sidebar small { display: none; } -.sidebar.closed .sidebar-group-title, .sidebar.closed .sidebar-label { display: none; } -.sidebar.closed nav a, .sidebar.closed .genre-link { justify-content: center; gap: 0; } -.sidebar.closed .nav-rule { display: block; margin: 12px 6px; } -.nav-rule { height: 1px; background: var(--line); margin: 12px 0; } +.nav-rule { height: 1px; margin: 12px 0; background: var(--line); } +.sidebar.closed a, .sidebar.closed .genre-link { flex-direction: column; justify-content: center; gap: 6px; height: 74px; min-height: 0; padding: 16px 0; border-radius: 10px; font-size: 10px; line-height: 1.2; } +.sidebar.closed a svg, .sidebar.closed .genre-link svg { width: 24px; height: 24px; } +.sidebar.closed .nav-rule { display: block; margin: 8px 12px; } -.content { max-width: none; padding: 12px 24px 80px 248px; margin: 0; } -.content-wide { padding-left: 84px; } -.youtube-home-header { display: none; } +/* 内容区 */ +.content { max-width: none; margin: 0; padding: 12px 24px 80px calc(var(--sidebar) + 24px); } +.content-wide { padding-left: calc(72px + 24px); } +.site-footer { padding: 28px 0 8px; color: #717171; font-size: 12px; text-align: center; } .youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; } -.youtube-category-bar { position: sticky; top: 56px; z-index: 2; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; } +.youtube-category-bar { position: sticky; top: var(--topbar); z-index: 10; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; } .youtube-category-bar::-webkit-scrollbar { display: none; } -.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 9px 14px; background: #2b2b2b; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; } -.youtube-category-chip:hover { background: #343434; } -.youtube-category-chip.active { background: #fff; color: #111; } -.video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px 16px; } +.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 8px 14px; background: #272727; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; font-weight: 500; } +.youtube-category-chip:hover { background: #3f3f3f; } +.youtube-category-chip.active { background: #f1f1f1; color: #0f0f0f; } +.video-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 16px; } .media-card { min-width: 0; } -.media-thumb { border-radius: 10px; background: #222; } +.media-thumb { border-radius: 12px; background: #222; } .media-thumb img { display: block; } .media-meta { align-items: flex-start; gap: 12px; padding-top: 12px; } -.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; } +.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; font-size: 14px; } .media-copy { min-width: 0; flex: 1; } -.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.media-meta small { color: #999; font-size: 13px; line-height: 1.5; } +.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.media-meta small { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.5; } .media-more { flex: none; border: 0; background: transparent; color: #fff; padding: 4px; cursor: pointer; } -.youtube-shorts-shelf { padding: 20px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } -.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 24px; } -.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-weight: 600; cursor: pointer; } -.shorts-mark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 13px; } -.shorts-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; } -.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 10px; } +/* Shorts 集合 */ +.youtube-shorts-shelf { padding: 24px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 22px; } +.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; } +.shorts-mark { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; background: var(--accent); color: #fff; font-size: 12px; } +.shorts-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; } +.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 12px; } .shorts-row .media-meta { display: none; } .short-media-title { display: -webkit-box; overflow: hidden; margin-top: 9px; color: var(--text); font-size: 14px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.language-menu { background: #222; border-color: #3b3b3b; box-shadow: 0 18px 45px #0009; } -.language-menu button { color: #ddd; } -.language-menu button:hover, .language-menu button.selected { background: #343434; color: #fff; } -@media (min-width: 1500px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } -@media (max-width: 1100px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } } +/* 提示与语言菜单 */ +.api-note { margin: 0 0 16px; padding: 10px 12px; border: 0; border-radius: 8px; background: #181818; color: var(--muted); font-size: 13px; } +.api-note.warning { background: #241a1a; color: #e6b8b3; } +.language-menu { top: 48px; width: 208px; padding: 8px; border: 1px solid #333; border-radius: 14px; background: #1f1f1f; box-shadow: 0 20px 50px #000a; } +.language-menu-header { padding: 7px 10px 8px; color: #8a8a8a; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; } +.language-menu button { gap: 10px; padding: 10px; border-radius: 9px; color: #ddd; } +.language-menu button:hover { background: #2b2b2b; color: #fff; } +.language-menu button.selected { background: #ffffff0f; color: #fff; } +.language-option-label { flex: 1; font-size: 14px; } +.language-option-check { flex: none; color: #ff8a82; } +.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 15px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; } + +/* Shorts:客户端式全屏竖滑视频流 */ +.shorts-stage { position: fixed; top: var(--topbar); left: var(--sidebar); right: 0; bottom: 0; z-index: 6; margin: 0; overflow: hidden; background: #000; } +.shorts-stage-wide { left: 72px; } +.shorts-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; overscroll-behavior-y: contain; scrollbar-width: none; } +.shorts-scroll::-webkit-scrollbar { display: none; } +.short-slide { position: relative; height: 100%; overflow: hidden; background: #050505; scroll-snap-align: start; scroll-snap-stop: always; } +.short-slide-stage { position: relative; display: flex; align-items: center; justify-content: center; width: min(100%, calc((100vh - var(--topbar) - 24px) * 9 / 16)); height: 100%; margin: 0 auto; padding: 12px 0; } +.short-column { display: flex; flex-direction: column; justify-content: center; width: 100%; height: 100%; } +.short-player-wrap { position: relative; flex: 1; min-height: 0; } +.short-player-wrap::after { content: ''; position: absolute; inset: auto 0 0; display: none; height: 32%; background: linear-gradient(transparent, #000b); pointer-events: none; } +.short-slide-player { width: 100%; height: 100%; } +.short-slide-player .video-player { width: 100%; height: 100%; object-fit: contain; } +.short-slide-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; } +.short-slide-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .4; } +.short-slide-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; } +.short-slide-info { position: absolute; right: calc(100% + 24px); bottom: 12px; width: clamp(0px, calc((100vw - var(--sidebar) - 100%) / 2 - 24px), 340px); margin: 0; padding: 0; pointer-events: auto; text-shadow: none; } +.short-channel { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; pointer-events: auto; } +.short-avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 14px; font-weight: 700; } +.short-channel-name { color: #fff; font-size: 14px; font-weight: 600; } +.short-follow { border: 0; border-radius: 16px; padding: 6px 14px; background: #fff; color: #111; font-size: 12px; font-weight: 600; cursor: pointer; } +.short-follow.followed { background: #ffffff2b; color: #fff; } +.short-slide-info h1 { display: -webkit-box; overflow: hidden; margin: 6px 0 5px; color: #fff; font-size: 18px; font-weight: 600; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.short-rail { position: absolute; left: calc(100% + 16px); bottom: 20px; z-index: 4; display: grid; gap: 16px; justify-items: center; } +.short-rail button { display: grid; justify-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #fff; cursor: pointer; } +.short-rail .rail-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #ffffff1f; color: #fff; } +.short-rail button:hover .rail-icon { background: #ffffff33; } +.short-rail button.active .rail-icon { background: #ff0033; color: #fff; } +.short-rail small { color: #eee; font-size: 11px; line-height: 1.2; text-shadow: 0 1px 6px #000; } +.shorts-toast { position: fixed; left: 50%; bottom: 28px; z-index: 80; transform: translateX(-50%); max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 8px; background: #fff; color: #111; font-size: 13px; word-break: break-all; } +.shorts-state { position: absolute; top: 20px; left: 50%; z-index: 8; transform: translateX(-50%); padding: 10px 14px; border-radius: 8px; background: #111c; color: #ddd; font-size: 13px; white-space: nowrap; } +.shorts-state.warning { color: #ffb0a8; } + +/* 评论面板 */ +.comments-backdrop { position: fixed; top: var(--topbar); right: 0; bottom: 0; left: 0; z-index: 70; border: 0; background: #0008; } +.comments-sheet { position: fixed; top: var(--topbar); right: 0; bottom: 0; z-index: 71; display: flex; flex-direction: column; width: min(420px, 100%); background: #181818; border-left: 1px solid #303030; } +.comments-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid #303030; } +.comments-header h2 { margin: 0; color: #fff; font-size: 16px; font-weight: 600; } +.comments-header button { display: grid; place-items: center; border: 0; background: transparent; color: #fff; cursor: pointer; } +.comments-list { flex: 1; display: grid; align-content: start; gap: 16px; padding: 16px; overflow-y: auto; } +.comments-note { margin: 0; color: #aaa; font-size: 13px; } +.comments-note.warning { color: #ffb0a8; } +.comment-row { display: flex; gap: 10px; } +.comment-avatar { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: 50%; background: #734c67; color: #fff; font-size: 13px; font-weight: 700; } +.comment-body { min-width: 0; } +.comment-body b { color: #fff; font-size: 13px; } +.comment-body p { margin: 4px 0; color: #eee; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } +.comment-body small { color: #888; font-size: 11px; } +.comment-form { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #303030; } +.comment-form input { flex: 1; min-width: 0; height: 40px; padding: 0 14px; border: 1px solid #3b3b3b; border-radius: 20px; background: #121212; color: #fff; } +.comment-form button { display: grid; place-items: center; width: 44px; height: 40px; border: 0; border-radius: 20px; background: #ff0033; color: #fff; cursor: pointer; } +.comment-form button:disabled { opacity: .35; cursor: not-allowed; } +/* 播放页 */ +.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; max-width: 1280px; margin: 0 auto; padding: 8px 0 32px; align-items: start; } +.watch-primary { min-width: 0; } +.watch-player { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #000; touch-action: none; } +.watch-player .video-player { width: 100%; height: 100%; object-fit: contain; } +.watch-player-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; } +.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .35; } +.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; } +.watch-swipe-tip { position: absolute; top: 12px; right: 12px; z-index: 2; display: none; padding: 5px 10px; border-radius: 999px; background: #0009; color: #ddd; font-size: 11px; pointer-events: none; } +@media (max-width: 760px) { .watch-swipe-tip { display: block; } } +.watch-arrows { position: absolute; top: 50%; right: 12px; z-index: 3; display: grid; gap: 8px; transform: translateY(-50%); } +.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #ffffff24; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; backdrop-filter: blur(6px); } +.watch-arrows button:disabled { opacity: .35; cursor: not-allowed; } +@media (max-width: 760px) { .watch-arrows { display: none; } } +.watch-title { margin: 16px 0 12px; color: #fff; font-size: 20px; font-weight: 600; line-height: 1.4; } +.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; } +.watch-channel { display: flex; align-items: center; gap: 12px; min-width: 0; } +.watch-avatar { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 15px; font-weight: 700; } +.watch-channel-copy { min-width: 0; } +.watch-channel-copy b { color: #fff; font-size: 14px; font-weight: 600; } +.watch-subscribe { margin-left: 2px; border: 0; border-radius: 20px; padding: 8px 16px; background: #fff; color: #111; font-size: 13px; font-weight: 600; cursor: pointer; } +.watch-subscribe.followed { background: #272727; color: #fff; } +.watch-actions { display: flex; align-items: center; gap: 8px; } +.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; } +.watch-actions button:hover { background: #343434; } +.watch-actions button.active { background: #ff0033; } +.watch-description { margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #1a1a1a; color: #ccc; font-size: 14px; line-height: 1.6; } +.watch-description b { display: block; margin-bottom: 4px; color: #fff; font-size: 13px; font-weight: 600; } +.watch-description p { margin: 0; white-space: pre-wrap; } +.watch-episode { margin: -6px 0 12px; color: #aaa; font-size: 13px; } +.watch-side { min-width: 0; } +.watch-side h2 { margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600; } +.watch-episode-list { display: grid; gap: 8px; } +.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: transparent; text-align: left; cursor: pointer; } +.watch-episode-item:hover { background: #1f1f1f; } +.watch-episode-item.active { background: #2a2a2a; } +.watch-episode-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; } +.watch-episode-thumb img { width: 100%; height: 100%; object-fit: cover; } +.watch-episode-thumb b { position: absolute; right: 4px; bottom: 4px; padding: 1px 4px; border-radius: 3px; background: #000c; color: #fff; font-size: 11px; font-weight: 600; } +.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #0009; color: #fff; } +.watch-episode-copy { display: grid; gap: 3px; min-width: 0; align-content: center; } +.watch-episode-copy b { color: #fff; font-size: 13px; font-weight: 500; } +.watch-episode-copy small { color: #aaa; font-size: 12px; } +.watch-side-card { display: flex; align-items: flex-start; gap: 10px; } +.watch-side-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; } +.watch-skeleton-player { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; } +.watch-skeleton-title { width: 72%; height: 26px; margin-top: 18px; } +.watch-skeleton-row { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.watch-skeleton-channel { width: 160px; height: 36px; border-radius: 18px; } +.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 12px; } +.watch-skeleton-heading { width: 90px; height: 18px; margin-bottom: 16px; } +@media (max-width: 1180px) { .watch-page { grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } } +@media (max-width: 980px) { .watch-page { display: block; } .watch-side { margin-top: 28px; } } + +/* 观看记录 */ +.history-heading { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 20px; } +.history-heading h1 { margin: 0; color: #fff; font-size: 22px; font-weight: 600; } +.history-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; } +.history-card { min-width: 0; } +.history-thumb { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #222; } +.history-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; } +.history-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #ffffff26; } +.history-progress i { display: block; height: 100%; background: #ff0033; } +.history-title { display: -webkit-box; overflow: hidden; margin-top: 10px; color: #fff; font-size: 15px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.history-card small { display: block; margin-top: 4px; color: #aaa; font-size: 12px; } + +/* 骨架屏 */ +.skeleton { display: block; border-radius: 8px; background: #272727; animation: skeleton-pulse 1.5s ease-in-out infinite; } +@keyframes skeleton-pulse { 0%, 100% { opacity: .45; } 50% { opacity: .85; } } +@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; opacity: .6; } } +.skeleton-card { pointer-events: none; } +.skeleton-thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; } +.skeleton-thumb.compact { aspect-ratio: 9 / 16; } +.skeleton-meta { display: flex; gap: 12px; padding-top: 12px; } +.skeleton-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; } +.skeleton-lines { flex: 1; display: grid; gap: 8px; padding-top: 2px; } +.skeleton-line { width: 92%; height: 15px; } +.skeleton-line.short { width: 58%; height: 12px; } +.skeleton-short-title { width: 90%; height: 14px; margin-top: 10px; } +.skeleton-chip { flex: none; width: 72px; height: 34px; border-radius: 8px; } +.skeleton-chip:first-child { width: 54px; } +.history-skeleton .skeleton-line { width: 88%; height: 15px; margin-top: 10px; } +.history-skeleton .skeleton-line.short { width: 46%; height: 12px; } +.shorts-skeleton { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; } +.shorts-skeleton-wrap { position: relative; width: min(100%, calc((100vh - var(--topbar) - 170px) * 9 / 16)); height: 100%; padding: 12px 0; } +.shorts-skeleton-screen { width: 100%; height: 100%; border-radius: 12px; } +.shorts-skeleton-rail { position: absolute; right: -62px; bottom: 120px; display: grid; gap: 16px; } +.skeleton-dot { width: 46px; height: 46px; border-radius: 50%; } +.shorts-skeleton-copy { position: absolute; right: 4px; bottom: 30px; left: 4px; display: grid; gap: 8px; } + +/* 关于 YMB */ +.about-page { max-width: 1000px; margin: 0 auto; padding: 8px 0 24px; } +.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(250px, .75fr); gap: 32px; align-items: center; overflow: hidden; padding: 56px 48px; border: 1px solid #2a2a2a; border-radius: 24px; background: linear-gradient(135deg, #0c1a30, #141414 55%, #2a0d16); } +.about-hero::before { content: ''; position: absolute; top: -140px; right: -60px; width: 420px; height: 420px; border-radius: 50%; background: radial-gradient(circle, #1f6feb55, transparent 65%); } +.about-hero::after { content: ''; position: absolute; bottom: -180px; left: 22%; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff003344, transparent 65%); } +.about-hero-copy, .about-hero-visual { position: relative; z-index: 1; } +.about-kicker { display: inline-flex; padding: 5px 10px; border-radius: 999px; background: #ffffff14; color: #9ecbff; font-size: 11px; font-weight: 600; letter-spacing: .14em; } +.about-hero h1 { max-width: 640px; margin: 18px 0 12px; color: #fff; font-size: 48px; font-weight: 700; line-height: 1.08; letter-spacing: -.02em; } +.about-hero p { max-width: 600px; margin: 0; color: #cfcfcf; font-size: 15px; line-height: 1.7; } +.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; } +.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 42px; padding: 0 20px; border-radius: 999px; font-size: 14px; font-weight: 600; } +.about-primary { background: #fff; color: #111; } +.about-secondary { border: 1px solid #ffffff2b; background: #ffffff14; color: #fff; } +.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; } +.about-stores-label { color: #8f8f8f; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } +.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; } +.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border: 1px solid #ffffff26; border-radius: 12px; background: #ffffff0f; color: #fff; transition: background .18s ease, border-color .18s ease; } +.about-store:hover { border-color: #ffffff4d; background: #ffffff1f; } +.about-store > span { display: flex; flex-direction: column; line-height: 1.2; } +.about-store small { color: #9c9c9c; font-size: 10px; } +.about-store b { color: #fff; font-size: 13px; font-weight: 600; } +.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 26px; } +.about-values span { padding: 5px 10px; border-radius: 999px; background: #ffffff0f; color: #bbb; font-size: 12px; } +.about-hero-visual { display: grid; place-items: center; min-height: 330px; } +.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; width: 188px; height: 322px; border: 1px solid #ffffff24; border-radius: 30px; background: linear-gradient(160deg, #0e2b55, #1b1b1b 60%, #350e19); box-shadow: 0 30px 80px #0008; } +.about-phone::before { content: ''; position: absolute; top: 12px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff38; } +.about-phone-glow { position: absolute; inset: 12px; border-radius: 22px; background: radial-gradient(circle at 50% 30%, #1f6feb33, transparent 60%); } +.about-phone-logo { position: relative; color: #fff; font-size: 34px; font-weight: 800; letter-spacing: -.04em; } +.about-phone-play { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: #ff0033; color: #fff; box-shadow: 0 10px 30px #ff003355; } +.about-phone small { position: relative; color: #9ecbff; font-size: 10px; font-weight: 600; letter-spacing: .24em; } +.about-float { position: absolute; z-index: 2; padding: 6px 10px; border: 1px solid #ffffff26; border-radius: 999px; background: #ffffff17; color: #eaeaea; font-size: 11px; white-space: nowrap; } +.about-float-a { top: 26px; left: 0; } +.about-float-b { top: 50%; right: -4px; } +.about-float-c { bottom: 24px; left: 12px; } +.about-section { margin-top: 40px; } +.about-section > h2 { margin: 0 0 12px; color: #fff; font-size: 24px; font-weight: 600; } +.about-lead { max-width: 760px; margin: 0; color: #b7b7b7; font-size: 15px; line-height: 1.8; } +.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 20px; } +.about-pillar { padding: 24px; border: 1px solid #2a2a2a; border-radius: 16px; background: #1a1a1a; } +.about-pillar-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: #ffffff10; color: #9ecbff; } +.about-pillar h3 { margin: 18px 0 8px; color: #fff; font-size: 17px; font-weight: 600; } +.about-pillar p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; } +.about-roadmap { margin: 16px 0 0; padding: 0; list-style: none; } +.about-roadmap li { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #262626; } +.about-roadmap li:last-child { border-bottom: 0; } +.about-step-index { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: #ffffff10; color: #9ecbff; font-size: 12px; font-weight: 600; } +.about-roadmap b { display: block; margin-bottom: 4px; color: #fff; font-size: 15px; font-weight: 600; } +.about-roadmap p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; } +.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-top: 44px; padding: 32px; border: 1px solid #2a2a2a; border-radius: 20px; background: linear-gradient(120deg, #12233f, #1b1b1b 55%, #33101a); } +.about-cta h2 { margin: 0 0 8px; color: #fff; font-size: 22px; font-weight: 600; } +.about-cta p { margin: 0; max-width: 560px; color: #b7b7b7; font-size: 14px; line-height: 1.7; } + +@media (max-width: 960px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } } + +@media (max-width: 900px) { .short-rail { left: calc(100% + 8px); } } +@media (min-width: 1700px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } +@media (max-width: 1180px) { .create-button { width: 40px; height: 40px; padding: 0; border-radius: 50%; justify-content: center; } .create-button span { display: none; } } +@media (max-width: 1020px) and (min-width: 761px) { + .topbar { display: flex; column-gap: 12px; } + .topbar-left { flex: none; } + .topbar-center { flex: 1; } + .top-actions { margin-left: 0; } +} +@media (max-width: 1180px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } } + @media (max-width: 760px) { - .topbar { height: 56px; padding: 0 12px; gap: 9px; } - .brand { min-width: 74px; } - .brand img { width: 74px; height: 34px; } - .voice-search, .create-button, .notification-button { display: none; } + .topbar { display: flex; align-items: center; gap: 8px; height: var(--topbar); padding: 0 8px; } + .topbar-left { flex: 1; gap: 4px; } + .topbar-menu { width: 40px; height: 40px; } + .brand img { width: 74px; height: 28px; } + .topbar-center { display: contents; } + .create-button, .notification-button { display: none; } + .mobile-search { display: grid; width: 40px; height: 40px; border-radius: 50%; color: var(--text); } + .top-actions { gap: 6px; margin-left: auto; } .language-switcher { display: block; } - .language-trigger { width: 38px; height: 38px; justify-content: center; padding: 0; } - .language-trigger span, .language-trigger b, .language-trigger svg:last-child { display: none; } - .top-actions { gap: 8px; } - .search-form { position: absolute; top: 58px; left: 12px; right: 12px; max-width: none; margin: 0; } - .content, .content-wide { padding: 0 12px 88px; } - .youtube-category-bar { top: 56px; margin: 0 -12px; padding: 10px 12px; } + .language-trigger { width: auto; height: 36px; padding: 0 10px; justify-content: center; gap: 0; } + .language-trigger .language-code { font-size: 12px; } + .search-form { display: none; } + .search-form.open { position: absolute; top: var(--topbar); left: 0; right: 0; z-index: 40; display: flex; max-width: none; height: auto; margin: 0; padding: 8px 12px; border-radius: 0; background: var(--bg); border-bottom: 1px solid #222; } + .search-form.open input { height: 40px; border-radius: 20px 0 0 20px; } + .search-form.open button { height: 40px; border-radius: 0 20px 20px 0; } + .content, .content-wide { padding: 0 12px 84px; } + .youtube-category-bar { top: var(--topbar); margin: 0 -12px; padding: 10px 12px; gap: 8px; } + .youtube-category-chip { padding: 7px 12px; font-size: 13px; } .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; } .media-meta { gap: 8px; padding-top: 9px; } .channel-avatar { width: 28px; height: 28px; font-size: 12px; } - .media-title { font-size: 13px; } - .media-meta small { font-size: 11px; } + .media-title { font-size: 14px; } + .media-meta small { font-size: 12px; } .media-more { display: none; } - .youtube-shorts-shelf { margin: 28px -12px 34px; padding: 16px 12px 22px; } + .youtube-shorts-shelf { margin: 20px -12px 32px; padding: 16px 12px 22px; } .youtube-shelf-heading h2 { font-size: 20px; } - .shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; } + .shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; padding-bottom: 2px; } .shorts-row .media-card { flex: 0 0 150px; } .shorts-row .media-thumb { aspect-ratio: 9 / 16; } - .youtube-home-feed-secondary { margin-top: 0; } - .bottom-nav { height: 64px; background: #151515; border-top: 1px solid #292929; } - .bottom-nav a { color: #aaa; } + .shorts-stage, .shorts-stage-wide { left: 0; bottom: 64px; } + .short-slide-stage { display: block; width: 100%; padding: 0; } + .short-column { display: block; height: 100%; } + .short-player-wrap { position: static; flex: none; height: 100%; } + .short-player-wrap::after { display: block; } + .short-slide-info { position: absolute; left: 16px; right: 84px; bottom: 22px; width: auto; margin: 0; padding: 0; pointer-events: none; text-shadow: 0 2px 14px #000; } + .short-channel { gap: 8px; margin-bottom: 8px; } + .short-avatar { width: 32px; height: 32px; font-size: 13px; } + .short-channel-name { font-size: 13px; } + .short-follow { padding: 5px 12px; font-size: 11px; } + .short-rail { left: auto; right: 8px; bottom: 104px; gap: 12px; } + .short-rail .rail-icon { width: 42px; height: 42px; } + .short-rail small { font-size: 10px; } + .shorts-stage .feed-arrows { display: none; } + .shorts-toast { bottom: 84px; } + .shorts-skeleton-wrap { width: 100%; height: 100%; padding: 0; } + .shorts-skeleton-screen { border-radius: 0; } + .shorts-skeleton-rail { right: 8px; bottom: 104px; gap: 12px; } + .skeleton-dot { width: 42px; height: 42px; } + .shorts-skeleton-copy { right: 84px; bottom: 22px; left: 16px; } + .comments-sheet { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: min(82vh, 640px); border-top: 1px solid #303030; border-left: 0; border-radius: 16px 16px 0 0; } + .comments-backdrop { top: 0; } + .upload-backdrop { padding: 12px; } + .upload-dialog { padding: 18px; } + .about-page { padding: 4px 0 16px; } + .about-hero { padding: 32px 22px; border-radius: 18px; } + .about-hero h1 { font-size: 32px; } + .about-hero p { font-size: 14px; } + .about-hero-actions { margin-top: 20px; } + .about-stores { margin-top: 22px; } + .about-stores-row { flex-direction: column; } + .about-store { justify-content: flex-start; } + .about-values { margin-top: 20px; } + .about-section { margin-top: 32px; } + .about-section > h2 { font-size: 21px; } + .about-pillars { grid-template-columns: 1fr; gap: 12px; } + .about-pillar { padding: 20px; } + .about-cta { flex-direction: column; align-items: flex-start; padding: 24px 20px; } + .about-cta .about-primary { width: 100%; } + .short-slide-info h1 { font-size: 16px; } + .shorts-state { top: 12px; } + .watch-page { max-width: none; padding: 0; } + .watch-player, .watch-skeleton-player { width: calc(100% + 24px); margin: 0 -12px; border-radius: 0; } + .watch-title { font-size: 18px; margin: 14px 0 10px; } + .watch-toolbar { gap: 12px; } + .watch-channel { width: 100%; } + .watch-actions { width: 100%; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; } + .watch-actions::-webkit-scrollbar { display: none; } + .watch-actions button { flex: none; } + .watch-side { margin-top: 28px; } + .history-heading h1 { font-size: 20px; } + .bottom-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; display: flex; height: 64px; background: #151515; border-top: 1px solid #292929; } + .bottom-nav a { flex: 1; display: grid; place-items: center; gap: 3px; color: #aaa; font-size: 10px; line-height: 1.2; } .bottom-nav a.active { color: #fff; } - .sidebar { display: none; top: 56px; bottom: 64px; width: 246px; padding: 16px 12px; box-shadow: 16px 0 30px #0008; } + .sidebar { display: none; top: var(--topbar); bottom: 0; z-index: 50; width: min(240px, calc(100vw - 56px)); padding: 12px; overflow-y: auto; background: var(--bg); box-shadow: 0 0 24px #000a; } .sidebar.open { display: block; } .sidebar.closed { display: none; } - .scrim { background: #0006; } + .scrim { position: fixed; inset: 0; z-index: 45; background: #0006; border: 0; } }