From 801fd28aed5d22271a1f9d8cb02b0c0fcde9947d Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Sun, 25 Feb 2024 21:21:57 +0100 Subject: [PATCH 001/110] convert png files to webp to reduce apk size --- .../res/drawable-nodpi/ui_widget_preview.png | Bin 135756 -> 0 bytes .../res/drawable-nodpi/ui_widget_preview.webp | Bin 0 -> 89536 bytes app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 4786 -> 0 bytes app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 3856 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 3083 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 2558 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 6832 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 5290 bytes app/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 10648 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 7912 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 15544 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 10526 bytes 12 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 app/src/main/res/drawable-nodpi/ui_widget_preview.png create mode 100644 app/src/main/res/drawable-nodpi/ui_widget_preview.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/app/src/main/res/drawable-nodpi/ui_widget_preview.png b/app/src/main/res/drawable-nodpi/ui_widget_preview.png deleted file mode 100644 index a46297336dfb15ebfc56790ce699315a93488b4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 135756 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

bg-)&eZw3@w}K>o`L9i(oz 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 z zm;3PZAk3xvtv7$CC?@Rna-ftgwVcI8rJ4Pd^sSq9s<8JGTFD&p4eHue@-oHzz+A z2c$mUPvh`cdx{`@H7a-|xZoHoT8G|R`(dN;xbK8PBKUN!EwJZ{BDxTz?o=jQyW=nq z?--whTE(Dy;mKLukY>7_@fAj{st_&J$ync~uO)TkIo<(%l?a5#fOHTHTh85+CA?}F zyZNiksPZJt!uVKN0CcqDE9HMXlVwArD?Y4h{{p#n=<{s)!wrg_YyviaX40Ggj>d=2 zB?BVp@8;k48-v%1k+G6mY2B{jXf++2&vMGizl%E^!dGhQ9{vtcB1w*@gC*!8Br=i! zatsPA+#JA?mRWcGC_HQxHx{8@Yks?Vby_P(&9+P}0Ss1hHT*WRNhdXyp_Cq{u7!vl z3?G3x0!5*6EeiWX{Hu?1(v5({Zth8Ti0{Np@6F%UpOZlpyMF}dc;=ew{tBFvO=_3P z9-GwY&At@~>{)$;Q(?yzFFogjKBvV;c;4(qWE;2x)!@Eq5;;Zi zlr{cUW5o!KINu1{UIfeBv1t^+?zbd*zw|t;EUp;S)~NOMX?<4cc6-XSNOuZv#K6y6 z8xLd`Hn++(t^m)xI41oRRoh(4bv31n;r!OIfG*P&!?YULb~x@DOUrXlnakf8!t%y~ zlgZZg9TnafVq?nExG62_eFReysRU3zdTElu%Eo_pBE6V8PFPryW1E~CY{OVrl|S63 zsEAjTWC`5RlS5?6$G)u;DzWWlwPnthQ&naPz`GR>JQNXtum<_^0W>Qwf?TOA^07i zGhSSCdK-q%nv??;YDRti*Qc>4J=Dq52OIN4lIGJF#d?L0n-&xPr>nJ_2s zoR^KX%@XP#e8Xp=sv6ZzF(o2{;3{K}elawa_I{f7livA8nP1lDT&tXp#Neg0gZoE` z|9z#|#muNP=_MUxY>`N4fEQnYm2`8M%5HRR5loK_`zc#m&-;wmhcj13vC&D?hSkxR zBM~fF1X9l}lrQ&GukxKw`ioO};;>qdkPB7=u?@>FP~|64>JJ5( zb=xak$@RY(t#<-yT>MFX0QvsZLwCTE=1*1BoCyk}Abw=+5bF>4RT!YH(o)0N9U>e` zNuLfJTQ2%8H$ED<-yG~1!>sKFe^X@)|D`TdoM~-l@Rl5>%=NvkawUTEDBEg+4^sQH zh>SILd+|vf(QOg~r_)4sB0I*w;CC|5WWb*87?6qZ!3jBz9#UeB%#%ar6g4$ zFh?CxBle4htIYa1ytgF(ZS(P`C2SMn=ot*_Qtm))>kMui|KWY|`Kl%2nL4{edm`l_ z9zwfm>yfd3k31S}4JGD{?|Yi;D+b&6 zgy=-Sx|y-F*RT0DTJX_GP%rjiqJ`;^JdPd*?U97Bp5O|L1%P$hZksW~GKTM1_2&Ffsqwi*w$b*(#I%6%79W0um=totpY zO7|={kKAT6AgGe~o;ps}(tb*pk~`A!vQwcFIOMAC&b(0nt~n{2>Sh6)z^)JDZyJpax^ zr3QJcjqkNlt5v9M39Z&;&P0dAzW_%E`qi#YE`WA4ffDY?a9%SufCN+Z3DsfldiiiDLc#*(Ukf-P1dnl-zcjn-1P53DeJwb5mo>+I zpQIg-CB8}75F z*DL)pT4yyD;6h952ol+)HQMI>RN(G@A6BaRcpz}bgB9G-T}ry_nwZIeaB|f_u*ikx zuBEWD(xIGz>VSUq`P|W>ecZFe3$`hMeKjA-~}-ibvQx7N*IxTwQ>Xcwd?dG9~Y*~D%R*4Yo$aj z7{+LdTs61HM{OJO{$?l+CwZu^z<~zyD4}m3m|eDLT|NK=TgP8xhl^6pj{;9z-aX&@W?{uaFN| z%O7Z#)DDLT(MI4~O@U8zOmu^OcGo@Bj6~+QWKrZCi|sZ3TKQerS?y*#8-*O6nVsm3 z3M35WpD#6xH2LbCVl)!^4PF}Qpu-)~q-4j)tlNmCqQEj_V}Fu!c3*VB92U?t6(^Oa zu8U`^U_<9wieK?@gI4%`>i{~60xi1MK1|Oiu09$aTh|av$w~r1l)`zNYPqhuF|Zy{ zCEI~09WQYQP=9;j1JWN;n57T~8I$?d7_*(gFi=M&_~po4sZ`oi&8FOD(>Zu#$iA*8 z?;Ice3c(R0kD))1ztR=b*>kQ_ZJ|8BVTl<#-sWPl${DIDAAql@SmD4PQ`kK24$(@9 zlYcT~AGIyzpc=@ybBh|Wbmbl%p1q{!f2}6$YvvZ<846Kr9YbPAE1eG-PvNn9<6kt8 zR2C39uEtYPEmrCtbXX`)D9)*1vZJx{&iG?K=7clBOI1N%s;ac`G8)lv+P;!wtt^(E z?O6!FCxPu{W@KiHfXKt6LabpNTVq|svE%Z`NA?N~3HJ$?gz{Y0ki(?*ujpcjkvfM2 zh;c^Aa>Q4TgQlnGXQ$@DT#S!2JAcp5zBU^5ivstj>$z7TC90CE#!I&}5nmoNv0LAV z#E~>Fj2yLW#FN^{;Mt;?=u(}_vx6Zxy*(zuWbTc6h28(A7$q*ErEb_5*s#U0&L)!* z1w|yJ=|bD7E-jW%*L)gC_gC4pDK6BJ-XPIbg5I?0F^FW%`?F}|1~t&ad1q?#g~0Yu{5nJkMC9w|OR0+(bRz1h-ts@y5tldLCqKbhPN2 zOwXr{&*_M>6XrORg32tbntHH{FNj>P4I-*=11ZMDj&9?T(&1kRP{`KrgcjurhyvX$ z%^E%}(|b&m6+=U^u0O9Na25IX^iIq>PJYwqi+qw1`$?+t4$Mw5!_=HgDFk>(QUUtu zoRXj$c*$jq&m+S#Gj#*_W74=-8`pK~kL<&g?reH$QYgV#ATp zk%kVw&JcNa_G9VmIM$m?1!v^bB5jWxx6-~dyPrQ5v|9QJ9oiyEfvCj&=H{ED7b;|v z+ey0aXRunH!`hrJib1xBLM3_q%K1da1Fbxh$D@a|t?%j7wj$o;r9}5{-#VF`Mh6d0 zyJ#QQErLgUVPj7nLr+MJw4BeXOnoz)L!dDT_t zv7{IXX}~6mPoFq$R_{QG>R<8-m}-@gY&cr?wB_H+r=H8Ym|{qUz)LYNNbz*e9tvBt zRav0LPBjKA9;eYmHBPj3PfSyEu+X-?ynC36Pu<1W5!Yj#f~nE-zN&=c!_fsP15xt@ zvhG*57H|Uv{{_sbbOjVyZSr9)e>!iW`S=M5=&iI=I8@(_RVGtXzLWDS8#mW(&&P=sN6 z-;r_cK`|+f(Ich_Kn~7S+rmX8KrrA?E+tt-1*(S@z6WauL~^M{6ow59X?be^b3eK# zr+cyVT2A7EZ+~f3X>Q<6IZ5jw-X%YgiqvkqKjvcvG17UqpWUDW)J#@7FXg5C=BT(I5-l~gx*2i; zO}VUWp_{NOKTXFhVj!OG)7gWf!Boz;RN5K~*@g>DP6|IdOrgD>dexAgX!Q<9kduu5 zF3lFph5ETBU-4I<&`zW0Iz3y}7>@n1quU!0#3T2*O?K1!q3Gm(Pc`Jt9xZR1+=FW+ zDlbUN*KMXa7-ZpUVw+ReiPB7n1z?X${Jt1_H8mw0I(B&ut;Z}}xZuL&sh%CLnF&Y< z{k=WxqG3*PSvOWpvW%0a96OEOB=0Skfi@(MCZ=}5K2y9Tc?8{pVBPSyqp*?Qc;CeT zI@wVxQ?$(D&*+cSzJw;BA(re3DhHBL%L#($Y6li{dPd@ zagnT9B$ke^R*nIBv+i05PsT%ee8g0uhW18?-?O0$b%GRSF-uVZDOH~?4&Q*kt7O4? zzuv{!E)ANm!r!2%h&Q5M`A}t2u1O^5#I?)8=S&t4*^*h1q&as($Z#KdsY!hFHW6~+(pocMtd`c?SqqYF1bZ#30>?0nz^MT(WmQJ zFh*g|60=FaTU#AA}x_! zVsQs%(EMG_*JnaJl85lHQpJ+xEA2#)sd?h-bg(1OH)d!55K&h~MC$lVptw0>CYujx zPJU&@MuwgY{dCBE5jr=0v&kkQ3LZjZTBA+C3{=i=AFa%1L z*1$3sgQ_o=NDJ&D=F+P$ybf~Ca&oQ9d3K~oXv1mJNwZj6RQszY+uwju#iMsR*HC-y zJ8t?uOcnIT*FO<5^jZ$uF;n(MNR;3G)GsJP4f9@#wRj8I0WL#sL%o+KhI{olBk9q?A2hj%n-+i$c90>Q>ZYaY(L4n=hb`a9;Y2V1mH7HAZr z9b!{OY~UQy&eVu5ou5e+-x&Dqua1o0udeSIni=lrNlOdQh03q+qTJkz1|{!)Tpj8r z{F*>KKhY<)#2yszmGqVwVWnkyF8uZBA-GIMX13Z;GpN z1Zn4GA%)KbE`p*%&8_G0l$>@X*)jf7hki-399Cw>>)l08?Qt*Zw75fw`~gX2-svCQ zkn~M;95NX^YEf;$L2A~nhbd4Z5-bLxGc+KLwdtdbYGc6V%$)xPNxH=tJzVsIBL#!B_g3d~9jd{g6CRElNHiJJ>@C{CLuCrjWW9V3hYvN|7IVD!XE;vxHmhEa9X13YIwhS-Iz$sZ{`wz&i zo<^|F$&wAuhakAEjwV=mtz_h!@mk`xrl#M*koQxC*r#;A{n!6c%a-G7aW< zIDCVG(-`?C?RJ7|mw#x(Lfz0}OVc6i;4<;`sX{YN9uO+OzWcXOMZ*@~L$0$UB_H)j zZuLC0IS@PJL9Q&f%gzG*ssZ}(+8Tbbisx7|GUYa(DD7a>%klPiPDG@ajQ951iPRe2kLr+r%%@aceH^mOE)mX=Q%|Qf zj+`3yF`cywBC=F!$>qu=A5Z7JDm`mS(DcjwIHi_s(&~4)+tg&ERdF0V3%$&peChD| zz^qM=0ttiT^4P4TS{7<(JiD?;-pI#kQgmU?p~L;lyj~DtRd$Etf*i`R`cbJ+Rd(<1 zUW#j8HcjktEMwUsjL>=o3n~8hjaE4KyM{pmhLx+c&M+!J=4#w(c}8iE)EslI)Li42 z>X^rLioC8qdN|Wq~HDZOsYh ztW!qW)6+%FD$d4q+J+4mU9a3-dA+}&WN&v} zPWk< zC|OMk4n$x|nS!BShUIMcjY?E_hLu43xd>N779V246u1!ApS?O;Qjg3AQK~-0QLUlM zqQ^m+HHfhP8iPHawA8)U%?ZCDQF(@4^7Ek*a<(;=2UaR^C$nEkS{c#8cojSvlWM>c zB73OQ=G409Qt$I#JrGHydD>20Dp`>p{1G_cs3X@m3 zp4AFUzVsG%8N;uyg+7PsS*lXh+L;S?*5cO6Nu7vOt~_J5(Q-O_vlwCF^9TsY!;ra! zUW=FVh0H8jN*4u^B5oo^OCd?nPvl=T6|;wld6^hjlDa}sxRai{=nQMvXjm8OrYWa! zHlf1QC7bk!J*8D_F;U}+tE+U0zQkD5uXd8}hx@d4Ds*Cj5GfG9&6XDj(SHmWJM;Gb zVdf_$lh&xr!3Sj@3WONfz;5P&B^)T|FtV1o-6rR)Dth>$*RI;?YS^iOQVN;DLCq`a zSC;rCEnJeVnn3C`{2vwn$|=UyWct7OAqxX}qU#hY);7}17}pD$Bc`OFO};XwFw6kbd_k~h4^L#YSC={rztBeu8`Eoniz<<(XX9p zss8tHJ&5w_KwNA_S(0oKzs~~v?6NQ(shtmK?>zkUz1cg1)&5JM z`o5#4A?Q;8cuMu~;`ax#G_Ch^*$4s92P4H&?aZipE!}MnaTwYYs&<4`7V+pNl3e(n zeq!70zYL)8xz!u86ZBiqA8j+EXfU!aIxSoGTlTgRED>4$;0d=rF=&0Dx{%qMS|qR_ z^YE$hwZPGS|0(lmm)P+aw-Nd!q3xwml_R{_7m$4D%*oct&eFmNR$8?Oj3H>I%(c0t zZS~qHD+afD0Bw_yU+14K>M4foOMF__L-M%eqt(t2B_JS}8u4yWXwRu;^u)&GDhTyp zwO{L(&gRc{rIg3Ogqqd75#o6g*(tms@|29DoD7oVbW6GC@iF2V8{^eEN`yoZ4d9=m zqkM$FEXg*E`TBipqm#?wG+y$a@GVwXQgX>r3$_gWH5+wsg zm2u8=DLkirW;QtwbaOYXB#ReY$HI9e#z^79jQEjk1$g9|g8?)bjv%@?Fy5C!Q3(6? zpxPq%k<;v*Xyu5Lj5d6xfY8t zCr(2&I-H;Au2F5Z+Vb3tX!C0DuO)KQf*Koz#(*X1=8h>`lW};||g?MqZwciC2 zMk;rhMAs6(Rs!emZ!o&KuM{J|n1I8PW!fqG+}QRYKF&QwH5A^=(BhMU^ewxcI#N}f zopb!C;Sc?^!{-4i0fB1QUB}-Hwq23jTzez@@zvsVdKU5YMSF>vRvbU2&4aoHF5K)= znp>#!P#?=zK=f;B{`#lKaj4;d<=R}*RyRG;Aa1%(RRsgf_v7A(A6S2r*)vck4c_H9 z2Fod$NC3Q-(10s&QBia+O$tp)<)H(9)i6?8FHBZSha#s~%;k#=P~f@uy? zEM$buNQ|h`lR%$KC$*X@hL%w9zH0xBbegZ;bE2YjHcSpuv#50SxeCnhO_!;8qnG=( z<4aHCf$DLF9RGGN=PHvxYXzY#ukf7n?@Y0zV)@M1sy7r1~PkdvRxu56tt zwQ)@;F%3GE*giR{x*q-rN$O0zT!5#2NGg@xGdIc_T$HHy#+(v+Z%^Puvq{+Wl+mA_ z>HSK!)Eq(U`u({{E8wPq=0M4}C#F$R%|9U$o-Yqk7;`fOjS}9ZcNbr0Lx~=PxCwoy zTHY9g1(j{l*T^@u_{vB{pnxI?@I=s=QFfwHe(}!S6552hlu?3;VjpOdzGp%H-A-U2 zL6s@dI{aW#dlN4(-nThC(WLRkKb^Rv+$oTx62N5p=%dT!s zrHgwqGVR?LJ0~oW#6L|c%UvelPr_Q^0}K0~A~ZH59%RV&hRmz-i6`z^uy9EBmmEL+ z;;SX8x7C?TE*~7YE?KG^Yx_;d1EaZ6FAd`hY#BD$_Z~F1TWJt$*6Y}4&;hq#_8@~S z;fD-SG{~CNbD%gZ`utV$kYDy5kEF9#bT$5`gFDk!mL%R5^ia{@0Y8CZw%9CM2cYn1 z@rNK|N~fxme9F_k-4sjL;N@WXT&v2YkalN&i=Tn&K$N10SKEuD+c#rU2KsC6Ux zRh|1BWJ(G32`Zkd5MB1E9C6(UUA{yrp+EJm#43eD0H9FxcizP|H4tand`|Cz$4liJp==2nZC2Ay)9H6 zr>+dkG)pm(wzV+Sl$F%?0>AP&hM{0m%bK zB#Oy>oTF9#(rC}yiLW-1CSm?d4Wwn~69Fe!)981S?ZuGVx^ zd=J;y0wq%_!W)O1Wo?7qo;Ra8yP|=qDC5n7w?rbCJ%irmC2{?YO9Qbscep?^gdz3Hnphbn;a$s{j1-K zn6?&7Pf0rKG&WyUtE1=6dNGV!ezQ~Sh?enSPLJ@I_VV5kZ6 zhY1-5wbWRhb}SCIsS&r+c{N)ZlTgX<6z2@%wNH32)NGH4P-nt>n<-b2$X31#N7=Nc zx0-=Sg=SZ1+9*&Wqs#s<{YR{tKreUaBl{XFw>-ywOLx>tIC+TxMox|M?O=R>bXk{< z3h(qLSw>H7l@|UvZGoNY$))k0+J_v#Mq7qbd2gtuClN|dYfdycE@MTh-YSHX-Qir5 zyQ2H3vTTX24UV!$zagpKhlrBUlE-@hDDhyh1yteXaN|~%MD{LpVm&BqSS6_>*w90C z&??PTLuSPnl2||yPQh}@fjU!{;?j$J@TQUJo(0(|-2V_UT87c%7H@E8`0gdnNOYwx zQCJGu{SH=HM)&bE^%CP@9zoy{6$_kOalpu9VH%0zc{!N8- zOadrK$w6;ioOJ--4;{Ya%{hyL#UVtO!$)JyE1*+U^x~c59E=!EUv|n~E(+nQ5SZ`E z{8rGA(ZqCAvR*o^?Bn&B``g7|stm?u#Z=c{^6AEGo0ofu50Cw@5&w%t81%V30F@HA zR^PIMfT%!DbaL-NH8R!YN&%Lx9*xe7OQcl@bVbc;@g`ti=a$8y=A1bjdtl8+;Gra_ zGCnm@o1(g(%^Hif=|x2aGuQ)Rf=hY7eP$%Qh(4LhX(CWv#&(g|e`P^PqBWSY`Lp$x87Qfj+U@}ZW@?KL; zG+M8`U1`__vg3_J71wbbtddnU3dBB<*LOPI!;1G{@fl2cG}XQ3#3jU}9V9}x(CAFP zBVrJ`2p&+|nw3TxqnVv9zEM#n=C@SdcY4mGp_XeylaD7Iv81Qt*9~py?T!WKF&<}% z+UG^Tn?msc930*wHXTdhJNeAxUs22Hn7=al@_hapx=wQUO>0Br*S2q7swy`Tr$||@ zM%7)^HL2DJaWdARkzp?n^B&1trwg8sqPVgo7VN#;|AwjZAukA=2^Z;lcEhJTvc<6- zF{#7v0iceGE_*++Pim!CaZEQOK0*4s0&Pq^WKu(E_e-QIABS&w5T<9UQ*3F1QLz~a z;rZv`Kr!FJs8kfk*|OfV_0!qrXv*pg7wT>hHtxMKlbX@w#5tEI zTc0-KgU4-{_LVVY)kmHM*G>ZAGFkk;dkUlVr7}CRZPUFd*w!ap`(VsYoU>iP9l=cS zK3;lxuOVv_;bM?7Ow}$Sqv?dz?M}Sc<;YD4`&$n&P>p{1?{FvKGy49&GclO|nJ}dP z83e-r864jK8D`i28N<{6ng1V<{&|T1p#NX1{2xeQ0px$k|JMusKOtZKA0GZ6U;e)! z!lo7s5MO>}IG+UDoIU#xm+)O1h|zO>K2(Mim;E8qlSHo8l8op7q!4KJhph3rr`@=1 zwiWbrH{P&IvjD&Ad6f3J?}lOl)?%M`O}Mn1U#^=ycfDNK{NYb;A)oWbI6v9GeBnp` zU4clZ`Rz-;B9U%WZf(~Agm&jWX{Xac9r5l5vq1OrYSVqwbMA|+_nnB(;$8Gh0VRGw zj^~=_hQ~nd8r1)S)Zcm#j*pmiv*nJg_b1usEG%|(Chm|DA2B!C!1YVd+c@Nz89pNM zmoNWCYmM&UIE(8eskYM#bNi!e?Bvjb|FolP@giPJ+=2Vri`o0jm;VyxM&y0D>U!oz ze|ld+f0n8iAG2&BNNaW$1#?ztV_)X5^*9yz?(e{V?e&I0u8TU{>vDM&Supi|Dmq;r{L>$p8U~^ z(2ZK3^hw_{O=cTC#qj@JN~ZyI*+cuCPU@WIE-QeZcnR81Tux2Oh~zACJZXsFzWlcX zMsvD|v}JGpys>sVuhTJv?Jt>NgQ<6183?p&D5VuiC}K=Rkk--^Ra>m7 zEvY5Rd6UeCGxOnGbKWocko$e^`+e^J^E|)%zOMK6;R3rG4W+5dW z5}gyk8!rb`GtHD#5C~Xxh%29_8Vo8k8;BZUqs-!n%^B28QEo3VTD5lMq0Yh)+i=9( zfHh6c?2eo`txhI8C$G8&!*MU{X!0UeFq5hY%k*a?IdBs`aJ0i6BNm|kFl(-iaJz4~ zKE4^)2!t~e>`)o~K3yVN!@gOD<_!|fck6MQo6IRqb~Fwaz>x$Amth#R|IkFCj@I3I z(hy&V0M641U{L}(yr4j3yJwr<@VnaVPn(huMSnW(fJtroV%$<>p_^NEM)L_EQZU%4 z+5|JUdR#Qg+B1N_`%WMfB!pfItrFt6-P_qk3HO1Qd2z5H&Q~I@WuM=36LKzhdP@pN zhQ?TJk2fqm)H;v^8yz>nOswjO6Lzw|yDizaQ9j?MxuC$t;HYR~X*WqI9f$L(3c${mk3tUeT^TN!oYxd5mZlUm zXEjS_++v-;PG}eK_s#gqZGw=gj>)B=ODcY0o!Y|PEW~Yh$Tq%aRzAj%$8z>@#O{!> z|I*smq6V(*$z3DUX_{sjB0Je@V<=bej+UqW)&iaR3?9*&@k>k8n#UHK!wiML!wPtf z$Dyd!{DLT&p!^K!z;uh9W3ha-{li&nn}H55>uy&=!RdaAz;bqg(RAGJw@76VLKC$) zszRZ7^F%E5Van24!PJE@{VTSW6DwmH&0i?+3d7%CcV#F$TV1Rv06$UFC;&$tpv|lBeB~{80+J<@u-D+=rpMS`U zpHndMS}zyru;=Y;K{Lm^_3?3B+ZZl2@RG z4k>;}WoPN64LMs!9l=L_^u^sE9N4K^D0dOLQ&+SOD&f`kq)%0jOZuQo{zo3eGCi+^ zAtsy;!%6!yUv9-x1;Nw1gpn#T`7TMo&tGcfK@bgHpG!86(WlomCncb$g&`OCnc9Ea zhUw;7YTdL~Xu#Jmjb2)1=X<}*3)yF}N78&Eda6B20?Q{>ko2y(M3%fPKJdj(TVPAo z{g1_4>)aC=#!2rJ*p>PI3koe8=*gIG_I|6q61qGXI{}9H(z{fTansj<=dx(^vaPxQ z(ZtTlZ#A)hHfMj#;X^t37MVQE+$F#*9$RBa?E~nGtI04KKX8R56jj|%!crcnX!J_t zQ-A-`!7nH~d-~vU8~I>&TS2Ax+l{rcGY{Hvs|>AS4OI(9mvJUbJHr@O9~-^7;0aSc zCg_BfUUKEle21FXDz=qbeRYhZo@F2ki0MHVj_7ZEovqGaw3zDYVIA1&K$ zC}V^xid?ypP~aWYBQYKUZ7que{{24jl)P%wdVfvh7oMG9j?d5_?e_PN06*#%*MOY8)o^IFZ{CvGi+YZjUdZ0eyKG$|!!BQTpTzY4e4nsU;%zF?nUM z1*!2aJ1v~P{0(sbn(p2xt&S2)mk6jhA8I~JGr{uX^bgD8`=1t#{?Psgze zH)C~h@2Ui!$^0bi@SWh6gl>i~UD_E!c?{X24fzA9MIJ*g1Y-^TcNnz3&1*+AKZ8$G zDHsVBgqi}fGp)>d8ndoiOzoVS$I`ST zO&^%J6brVOGJ!+=2gTu|Qi_w09lYa)rna%z*y-=%7jgl5Ft22vHRAucS2~sA-3(5Wyke`8^0L07vGw%qSazXRPwU$b8b$fiFpE~&W@R% z0YVZZNq+ysOWZ1k^Lf=jkxE&ojFY)!W+G8a=nwI&IRMy89j-iGi0?oEr+z|=ITsX! zTy60aeGze-au?ZvgNe*RvWTf?mW)0pm>Sqf*97x+^{5zGw1s$$kJYdCp-19i7U0vr ziJ!*2G@8)_eBs)wQ8D7@|3bAxGHl~q>qs@jkyrm}Hlt~ZQLFW#uEAzbf!4j$LO&Bs z^3Nhj0WlNlB{5dSA2+Ps(#>s}RcIxDV5O3ftKX~)NcMv}&RnuU8WUI0DB+bL@ zj{F~Fd@z43w>-kh8o(&3$({H0rsBu9i~x}O@{j(Iz|mHBKe0#oiu>ovkp4_Yz1|6P zP!{~)&0!^dDbOt4pU?3}4gLfE+9pT}w|@F-O(`J}L;F4h8zrm$tU;CON=c7z(4lNq zL}h*oBwWN~3#X`pFjIW|(hjQo_q0Y;&kM$*(@yzBtNhg@GKh@r9@~g zo;yFvt+9WXMUdNNYwIN)pDtgk(a6-B6XhoBj1aA-xI4E{^Utrl$@bRv8CE!VEV)@k zm&Ulsio8p8vBJh>urYgI$}j*4Cg+@2)VMAlM$X>V>Z*!Ygk}6}wU5&r7c(11 zujF=Y{^SwPB8>-$LaF{mSQ*E!W8gLR37^1fT;-(4tkwJyXW_zN^~$Ve(+&aUjP+hK-B1KLt1?J`l+zx8euG@dfE@hFDO~&n&q>b@oZzPNY8#b|a zaN#phr$5XlJG~DWUnL-1gMf-iSf=fV{r!%^_QEW35nmX^oOd_Dj^P6vCeh`o;HXL- zcRU-iMzl1TLThTYPD@%GB*IvE@#P+}BK-m_lf383kA$rgk!paSztIu-8z#cq3Ap}O xU*!*zKYBL88e4e%sXOy$lfOjxH~L4me0W@Vc9(C#YQJ#o?=CypKC?k2{|BK5xq1Kq diff --git a/app/src/main/res/drawable-nodpi/ui_widget_preview.webp b/app/src/main/res/drawable-nodpi/ui_widget_preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..5b801e3f3d58fd086906acdf0a05a582abf29fd0 GIT binary patch literal 89536 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%0D13*jchWzdE$> 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

xUbGAL?#L`rGg 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

- zJYj!juY>e$4iql!n#TR6-!qOu>~-O?&{voQD^K42EFAuT0d|XXm%f1MQXC7H6>jQSkHCMi?uN3mEHM$gFB0+Ycsh_Q%R;m|jR;}r zEz6>-0_RxpKNS9H2}eNmFTn~oKKc!WqFC<=S(SL=z*@5$6nF!wH_GKLLP&alC8`R4 z^jt^6J}mtT=zb(sj?Y^NOEH3`3LGm=8MW}FriuT6@C$bc z*3m%RP)MM{O}nm&4TP&0K~6p8k=cIJBIBK4)6NH=UQaoBPWwiI@itADJpgzs6%7zv zk|skgBAYuJ$NZE0C6K@GM{#VeC}^_Dl{NVTpm0C1rm%pYO>{=Wdv!DAK0%=iyaD|e zA#M~jD)$NdV4LZW!iwA`@qMzl1BXA#v+uc{O66E1FcR|v7Qg>Rf6&@u1ep9D3f#_S zQeB`ym=gL|rvEVspOm+_#Uy(D<}~Dj&53^``o;Ux5auO+56ScPBPQZsgKCoJtLIdN zrJ0lGhfKy%AhRcF{{5@z2&b1$n*T5%C$3y%$7FeXZ%RT)<9}HOld}J&Lz2AxWm6 zwfIY1yMz^5h2(8Fa(83$cF^lrxyw8T&2~fa=7tWs?YqXA?>cDru4jCG4E@fB6IT~O z!;6r_QxOu-@!~Xb^bEB8?;@nQd7vx{=y_$HstS&ms)AMKIcRz{;nk8DC$%J069#2B z6y688c)(B?^u3YBn&`YdRK2H>255YF_F9;(G-LFPZrFca)O!G>j~@BDCjKrh8xUng z#uX+8y+1eR=MdQup?`)Zswxb+9|d+w;mn>9X=kf{gt`i#|FQEsv+@nDUEq!5@?zHA zN3Q@iC1B4$7c8`K5v}i&j;bN1lgoAWP=Y$_L1-an_HM=CGLAT;9BZvDw6jr91@<`n zV(nDxXS2Vw+YC8PbTrfVKlV_}g;>bhVD{Hy=X3w=94b!fkRi)ZsGhQtG_Z$b+I~qJ zrEHhBC-c}FxmS7q@L$`0-^aM9Ac4XpicwUO_Da#apaN%=cvRt6fg>FBzs~4%iP)hku!S}3n=_quYR7rg*J%ZVf})5hMNv?2qwclUY4zDU zZCz_^6=dIK5pY38Ky2$%s}}dxYPmQ6cg{aI7?K+Zkf@zGpU?T+kmct7zxkc>KmX-o zWb{mp^r4=T(wEnjum9&5GdI2yyyIFm{KV!W)qY52chhkuW)A0LSa|%?`?P3deJIpOg5XBbx;yF~$ z`9xX25mP{U5(eWV^M(ndvLnF17DnaV5YEgd0EpP2&&nke(4+IH+s5P(fI|Qdcmc7S zqj39J@Og#cMZ_Vl_=dy0lEZL}u$x;s?8!HiKI%Wn^JVxes)UgsDobJ|%h_oy%f4+L zb&LQY>ef-&Ed0%&gMt{F=p5E2I+q1Fa@&}Ex$SK90=Z~T0c$%aj|Di4{hWM;9_JOw z9RSkAIPm!;WKw+TZSb9r3(6(!*8ySo+$l=+mlH&0Nv&oa%e9gL4B0WIGgh)J2H{ZU zfa|Or^tpw^VIDeF5Ib>R5i@CiF*d|??8FFL;8WkJ<= z#q~iFe^!ZHEUPhAJ_aar1v8HQVl^X+z+<@}GLr$-$N>%)T84mALe+zC923gqlNXdQ zlNXlD#Q@1EvEq?)QsrE}YkexF{6Y&^?m)|kqelb?CK6nh)rQK!u+o6jorFBMb2v1t zI5<=};0T7oSya~Mlvqxj-mO4CDqd7GQ1zM(ZptsRjL5cweDfWJGb6JNj;vNVdQ?v5 zZ~zRz5hqr#&hM7Dxd0@W#g*Sd*x9RIyTMGE_yq492?jWilyETbJTGvZ5|0z-caIYa zC+T>bOHu`KUR?FaIiWB_wGDbVC4I;*uLB%{4&lXg3a1xg<(yDLB#EWHfTM8NqzcA$ zaV2qGQU&KM{HtnP^k&Kvj-Q?xq%)izD#vkt5t+B6o|K+%B`2Eiko1F10IE!9I24c) z4uS!9TU^PwC07HaO5&7|6Gq##T~CmdlC+FCf;s1rA>p8M-rQJATJJp~EXz`V{3}Dg zs{DbtEGp{~P6y>E;ec`!Udc$7oq!|sa0y2zskW`>#??52zh|0QMr6{2+$Ee|gcatT z^62>sVKhU_W5N~FswdIl%Cib$4L6G?IvaQIox}lZ z@5-M-LjK^`w;kXp{SheX3xMRYq(TD@Z&<0r0e1%lxi775^GK;7E{R21(6l~12Gkx} z?;MSNT5vEKf2aiz4L4BI;i~JzGr6LB%2C1rM>rnKYTDeFLDHU|y+F0Cx*IpxGk^P# zIly^{Mc~t_b2k$qPA3n_fP6LX%W4P@tzee1lSL!YtkJK9CnOaM{Qft~gQ|pLZV*Z!V)IK@` zQp#<7#9?nb@Px6IRye&($b3bhE`ZQ6aesZ{I`Lh8q5~XBCvXr9g|i&Jj(DY=l6$6r z66YQWR&9Id#wBK}h@&|;D(42CnsD@}9MvLl8xNpg9=8#O(S(F0z@D;e#6Jytrq1EC zD@O@uMIG@@t7E*=Ak>ly3ygG1*j#8?M||T#=W`UwG3cmVy+YoWqY0=Q3dX{-t-O#h z+zw&lUZwz}JDihLK|U+b5YLn<;y8D&w*o;Yq1Hauiahc$s)n1-F?d+1R>*vS3eVjl z{wt~>q;5csqzMVb?NIe~61cKX1CB;mad6Om(ocb(WW3?%mYB0!m4T)yi^6yI4FNc< z2nd`tlyX$(b5twjZ8_g-04M@Ln2bYK)sXCyKWY?`N+oSR_C2VfYvnwFbBcJcJk3g1 z*MkC&-*Wi-n$ZdrJfYUEp0~f8j&sP?vMhta;Zu&XLf)3E9S|mCOv?FZT6vZ9?aiOb zYa7n!4990B_^Q(Ysh;&ouO&|N4#cRk=xB=k(+9%qD+Ggc$WJS*REH1SwE$A>%0pJy zk#A~lYCyXD<3sYs=6W5%>IobvxbN!IjCA!GB1y{51O|Pl?j!1XNU=;adg^*pp)BhM z1f-7%c6B(~!b){))fphwt~?YV<=5Y$`3K+A2FEX}ZxElfI-TKAKmf*fbv^61_AG>1 z*^LRWC5=!ZQC-31dN0ej_WIIwtEe2gzTtGG9Oc+*d+udkKzwzMe|jC+oPC*mQ+tza z$oifHtUB3kI26#DGsJK0Ik_~whKOhF4o4Op!c6&x)!Sbt;G!N~vw8+_aBQ_bPdgxO zb&k)9+HU0>bvV9j>WP2GIR-+FxW*q?3MkYTAr%VhYaQ|RZUlpX^aV$kuu>hB|C0w$ z@%dX?;dD14^TF|33xL+1WBlPLS(LRGS*YZp_AMqa2xXb2n2;gN7@gqsG9mNL=V%3_ z1x|MpG9MhwKLMbifcMT5k0k{az~UvEgA{p)OGf7AzqgTPHKTHHIk-zWT_~p~aI^!u z%?n2#%0W2(859s3u&#l4r}N=x3cQKaOx7F2B1-4E3Jjmc@I%JxXXeUP8Z7QZbDXvgFV$pe1IBm8%8-9 z3OE7l&ZEnNHe4XSD{CGK|B~dO0cd2%3ZYF*-a}LlZb9iToZc!&S?7FI&U4WH!JyI=dOca67xYgws>y zMDI99N92uHe~H#fJg4)NhMkI zc_X=T_sNC?RmX6fz3W1Iozod0bvRurM@Km5p<6DJz>HJG?v3;}kcHZ;B8|1agC_Wm zvy08WhH&U^(q6(rkKcWPwDN=%`Ylk@()|}Z6oESm`2iUW&Zb5Zx~YjpKz?au*UfDF zt&v5?2{SOMGzEzb6wN&R4ONa}t8K5~EZg5qepK1YqUsoKal0F#`|db=;0JWqAYp}Y zLN+&%PzbNk&5guk;h`L0Fo<_fntQUJ)wB;|Q8^CtOW2+!WDF}*jy5ljqA<&jq;yAWzzg2fKsN|q}>S|)j9{qRe#%jnq0Z{q!3g( zF81LtLYs0zGES2NRs1Wb#gL>sh0}wuLO7vYn&n|zTS(B_IwA;-ejVG+n}1YIAPB{K zw1(TyJeW_Fv!I0028VCp>AT_tslCLnpqrqes2vT&b9qPQ1g<$nvQP6b2GSMN+^gE* z^iVkf2h?+s0Vf`d4wpikM+ida@ZX=NYFuwNV65N#sgChQL<~@Hi@z>#l%w*Poeku= zmb&M1>q9c6Z|LG4pZti$I0 z54KrLa@I6+bLsElFj5rp`C+_4qKY>ng4r% z1g<_s4pw7WX~IFT`}RJW_F)4*oSugj!Xe>XF1Cfi-;%gJxSBfBT>3V)Sw~akq5*?N z0iKiMa_>6<5XJWX&XuFSG!qAqez>EVkI?V@^eZWAyu%w-sGRDSdt}N+gD3~#u;CwE zBK~P*_lJx8eX*@vCzOk8*JQv=tPH@ z!z~Ip6>Yl0QPw$_kP*@kclZ;xQaKgPcgd?;&QmaYhXcJ80BV~8-MUY5_N9IU&BWL? zu~?>CV%BCO#eeJvhhb)u!ow%WW!-mOQmwf6p$j-NU;pt)_XG*gqpx%^cdfm>5~`avTn&IaJodq)ItY&D1B4Y8gBVsE zoC&Wa#9O&T^{Z zQBH5eic83tb9~cE?%RaTMCDkRjF#YpyyhN6Jz3uYqg?PmAB-R75dNo0vkzR7Bv%sm zlseWurH0iNjv+?n8gCqt~3EseK-Op^?h>>uO^je>xy*OZ$4aW9ZaM2jgf4t)u^+~Z? zQZehbqK4omZJ*WkEI?s=SDnG5ycclfbb@_zGfq>`nZS@uIOsjWy2tM;7Whw(z-z#3 z;c{j7##aV3s;2%!hvI)@u>f}%;rf?1?WcdR&vjwub+6P)svrd7xAq){662q7o}t^` zaA()rvuq%4U;+jW&D1R3I?n!VW=+1`W6L75+IXd2-aQY_~<3SG-e?6KQ%a@rN)|s0Ng> z{sMJW3vNNdT^6{J0N2WKIXD=Vw62~6tf?i^l!7a+^Y$FGpO%_DXp|5~;;0x43@1~8 zD;9t1o4}t-d%;X79Du(jJRSp%JGSfx3>o7!PWo1)-L#b%lVv*(y2Kr*a8Jx^@=DIT z=ABY<%O~~tJ)h;^DW&kZ=$hB!>?ZewuPU5pe{yirTNxPwzlcaYhmk@pc+Ro-9o&HP zHvvZ7`WguYzrzBPgN1wlEWvHS?Tn3$9l@Qk(Wc|YK|`$FLeWhH;vjquUUS0t;Cs2x z;AcnUxoFV}=ceEBZ(@CoM534Q*ESg~GX1Y%mP7mZA2J-C$8bb&nB>QCj*s!V5%?Z_ zFMft91lwEzGB M07*qoM6N<$f?Bd7{Qv*} diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..f22c5e0b4662a463ae20d5da1e702df194e747a5 GIT binary patch literal 3856 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 SKJX)~|4P5<4YygLi;Drq#clHd literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index a7b5a986e8c59818280d74675952d1afa90d752a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3083 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 Z{{x+x=?3j=9L4|u002ovPDHLkV1l?35_SLp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..2e4171a8334bfc71f29d1ab66369a3717d0fd26b GIT binary patch literal 2558 zcmV<|BMM6+kP&iEK2><{uFTe{BO&CbqhCTJK`XVCwA8d17s<=@SA%tq73= zb_bp3K05L}CJ>269unqxI} zqvQn&01M_-lb7p)Jb|nk0}CK>>|Wf)j*u8t|#2f}+cc>G zPN1GK7v{?ZioN3ZBj0~9!o~YiN?R`EfWkacq)J>9&zf246w~-zsv_UcoM}84y5m<} zJ6l^1dy;UmkM-IduzOSmB4<84GEQK%hJIEdi5X%RsVc4Zo37zKUYkvArYS0-&P3O= zX_vq?p0@rg^rk6WCR!L{5x=$IWlPd@5gSessq>lhshDV*ML`ussq7ax(HpJmrzEtM z*cTycvk}*MZFaRq(Me`at-$6QtoCf;Adp3ZSc2&D2S?x&v_%5 zCR#%BaKO&8#i*}#i3F9Ww23C#PX_~6-{_fFfT0?Rh}b&(`z@nTK{9<-A3Xh?)T6xM z)Vv@=yJArkr9y}i)%h!Ki~($*94G{09`21%TZ$8pwyP}iQ4(R%$JZhP*KOtI8xRD@^zsDwaJTc;Dy~=hEyf9`g2Fi*_hytS3 zsyJ}CF=i^-?exWSPrW>YERGD8sFnycl&J?BiUi`s-}jZb$G`&z^N@dHkN`P7h^nnw zuU9&;gh~E6CXkyw{r7#Cc=<^N%z5Gi0Jzxx7(g044!FZCj0f1XRW95rs{#P1K@eiI zz%V3j$xVO%QGrs|(yAP=J1A35&-xn5r1Z5u19&4?fG8K=?*UFa{)w3T%*1#^QL%Tz z>O$;&{nP+OJ^mapkXv`X&U1fWF?gCrDFs)pnFHD+ z0D$XgqDs6-O%0!Q(;4~kjgEZ(#xQt7`)65f;1amxUJroRz#E?&^RR>xKy=`8JCeV( zSQ-~1ndkbvofa+*qS1iJb4^|gTylQ^Xf(W=)>9__JabY*D58D>;WQ5DPuB!-hsdYh zrbaHcHxMZ<^TfzId7c-lwcqXUhBU}IXHA@T8j(6jEiSz`0Aw!vpNrE;^JX5M^00^R@0}Frz zprUV2^xw-5K7q%{3r_8m( z!t400d1~B!ju)lMt;T0v8d*C|m@EK;0saYBP6u#O)Hed)2PCo2jm!5tcW$?z(6ysj z54(FoAUWi3)(lH#8gQix`Qav_^;X4fGB)QwVU{dlxW;K3Feu>>8ramZ#R!r=d<-q9 z@_EYB&RneTT&7zK5H6y9iw^3z0PN5bQZZ9is8$EtsH3`p>=91g1Snas8ojn+8t_95-uP z(prl_f=f^ekP@i8_589PIJZYh%Hz911hl_YE=YkPh{X-@uOF_4)OB3miTsvn-f?v5 zLYy)%nXb^^zuI$zq2>wB-uF>E zTR+-ut9||80{>rNyE`H6VHu$n2~e;SwNTR{YJ@!2SMXm;Vn4P@x&veM!)LYb-iZT` zpI`A_EL~4lebvib5omnih`TSYdSSf0cxBOWcy3EZd~HCqV-Y1_tam$Mw`=&GY9W!v{$=z!-uGU#}+~`=u`8XKW&-~vz-NILYg78HtVhj1tAmF}JqC}|x(F!)Ww}s` z<)kpF{A)t$+m~4$J-&SV;mNBBWeP!k70MY7$W&uC3Ce_y@2ad z!<|(yemvLw(qUQ{Jt_^KsU+;yii<98iPM>?O#j_wx^a-NAN28;cW`TPfuz5m>C^iT UP_q4p*Z#umyS!`XQmokn0P-lu_5c6? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d293743cd09b67c63f35d29dd79e27edfbf9e3bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6832 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 S6TG#Qpff-*3^<_4j|BiHgbdyQ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index a149dfe6eb30aebac91fad8929675b7c9b5dc2e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15544 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 d#+($6pWX#lc|260 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 ZIX#U{w}H2#i8qn~hPtLYm0HfR{{zk@o`C=W diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..389bd1376c60a1a6f5469122db1744f8776a1dc7 GIT binary patch literal 10526 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 Date: Fri, 1 Mar 2024 07:41:00 -0700 Subject: [PATCH 002/110] info: add sample file to bug report template Should hopefully prevent a situation when someone will report a bug and then ghost me when I ask for a sample file. --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 7b94b9916..f83f6b5e7 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -57,6 +57,13 @@ body: placeholder: OnePlus 7T (LineageOS) validations: required: true + - type: textarea + id: sample-file + attributes: + label: Provide a sample file + description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors will indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. + validations: + required: true - type: textarea id: logs attributes: From b8b741b4c03819beddc108a6ea9c6ec5186002be Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 1 Mar 2024 07:42:11 -0700 Subject: [PATCH 003/110] Update bug-crash-report.yml --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index f83f6b5e7..cf09cc099 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -61,7 +61,7 @@ body: id: sample-file attributes: label: Provide a sample file - description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors will indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. + description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. validations: required: true - type: textarea From e452875d59cc9e76427cdc84072b25490b77c6c2 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 13 Mar 2024 03:49:23 +0100 Subject: [PATCH 004/110] Translations update from Hosted Weblate (#715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Czech) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pa/ * Translated using Weblate (Hindi) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hi/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (43 of 43 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/be/ * Translated using Weblate (French) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (Italian) Currently translated at 98.0% (307 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Finnish) Currently translated at 92.9% (291 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (44 of 44 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hans/ * Translated using Weblate (Interlingua) Currently translated at 42.1% (132 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Translated using Weblate (German) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Russian) Currently translated at 100.0% (44 of 44 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ru/ * Translated using Weblate (Korean) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Korean) Currently translated at 100.0% (44 of 44 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ko/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 99.0% (310 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Interlingua) Currently translated at 56.2% (176 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Translated using Weblate (Interlingua) Currently translated at 59.4% (186 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Translated using Weblate (German) Currently translated at 100.0% (44 of 44 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ * Translated using Weblate (Interlingua) Currently translated at 61.6% (193 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/ * Translated using Weblate (French) Currently translated at 100.0% (44 of 44 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fr/ * Translated using Weblate (Russian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (313 of 313 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ --------- Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: Сергій Co-authored-by: ShareASmile Co-authored-by: kopatych Co-authored-by: J. Lavoie Co-authored-by: 大王叫我来巡山 Co-authored-by: Software In Interlingua Co-authored-by: min7-i Co-authored-by: Макар Разин Co-authored-by: Yurical Co-authored-by: santiago046 Co-authored-by: qwerty287 --- app/src/main/res/values-be/strings.xml | 22 +-- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-fi/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 37 ++++- app/src/main/res/values-ia/strings.xml | 126 ++++++++++++++++++ app/src/main/res/values-it/strings.xml | 29 +++- app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 17 ++- app/src/main/res/values-ru/strings.xml | 2 +- .../metadata/android/be/full_description.txt | 1 + .../metadata/android/cs/full_description.txt | 1 + .../metadata/android/de/full_description.txt | 1 + .../android/es-ES/full_description.txt | 1 + .../android/fr-FR/full_description.txt | 6 +- .../metadata/android/hi/full_description.txt | 1 + .../metadata/android/ko/full_description.txt | 3 +- .../metadata/android/pa/full_description.txt | 1 + .../metadata/android/ru/full_description.txt | 1 + .../metadata/android/uk/full_description.txt | 1 + .../android/zh-CN/full_description.txt | 1 + 20 files changed, 231 insertions(+), 29 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 45df86ad1..e84a07e3d 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -16,7 +16,7 @@ Коска (,) Плюс (+) Амперсанд (&) - Уключэнні + Ўключыць Афармленне Паўтарыць Пошук у вашай бібліятэцы… @@ -27,7 +27,7 @@ Выдаць Песні Змяніце тэму і колеры праграмы - Усе песні + Ўсе песні Альбомы Альбом Жывы альбом @@ -58,7 +58,7 @@ Жанр Пошук Фільтр - Усе + Ўсе Назва Жанры Сартаваць @@ -84,7 +84,7 @@ Перайсці да альбома Перайсці да выканаўцы Праглядзіце ўласцівасці - Уласцівасці песні + Ўласцівасці песні Фармат Перамяшаць усё Бітрэйт @@ -121,8 +121,8 @@ Тэчкі Рэжым Выключэнні - Музыка не будзе загружана з папак, якія вы дадасце. - Музыка будзе загружацца толькі з папак, якія вы дадасце. + Музыка не будзе загружана з выбраных тэчак. + Музыка будзе загружана толькі з выбраных тэчак. Перасканаваць музыку Абнавіць музыку Перазагрузіце музычную бібліятэку, выкарыстоўваючы па магчымасці кэшаваныя тэгі @@ -148,7 +148,7 @@ Перайсці да апошняй песні Змяніць рэжым паўтору Значок Auxio - Уключыце або выключыце перамешванне + Ўключыце або выключыце перамешванне Выдаліць гэтую песню з чаргі Перамяшаць усе песні Спыніць прайграванне @@ -221,11 +221,11 @@ Прасунуты аўдыё кодэк (AAC) %1$s, %2$s Свабодны аўдыё кодэк без страты якасці (FLAC) - Уключыць не-музыку - Уключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў) + Выключыць іншыя гукавыя файлы + Ўключыць закругленыя вуглы на дадатковых элементах інтэрфейсу (патрабуецца закругленне вокладак альбомаў) Наладзьце элементы кіравання і паводзіны карыстацкага інтэрфейсу Экран - Укладкі бібліятэкі + Ўкладкі бібліятэкі Змяніць бачнасць і парадак укладак бібліятэкі Перайсці да наступнага Рэжым паўтору @@ -240,7 +240,7 @@ Гуляць ад выканаўцы Гуляць з жанру Запамінаць перамешванне - Уключайце перамешванне падчас прайгравання новай песні + Ўключайце перамешванне падчас прайгравання новай песні Кантэнт Музыка Аўтаматычная перазагрузка diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8ce66a7bb..4f1f2bee1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -232,7 +232,7 @@ Schrägstrich (/) Plus (+) Vom Künstler abspielen - Achtung: Verwenden dieser Einstellung könnte dazu führen, dass einige Tags fälschlicherweise interpretiert werden, als hätten sie mehrere Werte. Das kann gelöst werden, in dem vor ungewollte Trenner ein Backslash (\\) eingefügt wird. + Achtung: Verwenden dieser Einstellung könnte dazu führen, dass einige Tags fälschlicherweise interpretiert werden, als hätten sie mehrere Werte. Das kann gelöst werden, indem vor ungewollte Trenner ein Backslash (\\) eingefügt wird. Nicht-Musik ausschließen Audio-Dateien, die keine Musik sind (wie Podcasts), ignorieren Album-Cover diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 89a526160..63f4df9b6 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -299,4 +299,5 @@ ReplayGain-albumisäätö Soittolistan tuonti tästä tiedostosta ei onnistu Soittolistan vienti tähän tiedostoon ei onnistu + Valintakuva \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9fd021428..839ea4dce 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -127,7 +127,7 @@ Genre Égaliseur Lecture aléatoire de tous les titres - Auxio icône + Icône Auxio Couverture de l\'album Genre inconnu Dynamique @@ -142,7 +142,7 @@ Ce dossier n\'est pas pris en charge Réinitialiser Ogg audio - Violet Claire + Violet foncé Audio MPEG-1 Échec du chargement de la musique Wiki @@ -166,10 +166,10 @@ Esperluette (&) Playlist Lors de la lecture à partir des détails de l\'élément - Gardez la lecture aléatoire lors de la lecture d\'une nouvelle chanson + Garder la lecture aléatoire lors de la lecture d\'une nouvelle chanson Lire à partir de l\'élément affiché N\'oubliez pas de mélanger - Contrôlez le chargement de la musique et des images + Contrôler le chargement de la musique et des images Musique Images Qualité améliorée (chargement lent) @@ -195,7 +195,7 @@ Lire depuis l\'album Barre oblique (/) Plus (+) - Vider l\'état de lecture précédemment enregistré (si il existe) + Vider l\'état de lecture précédemment enregistré (s\'il existe) Ajustement avec étiquettes Dossiers de musique Gérer d\'où la musique doit être chargée @@ -223,7 +223,7 @@ Scanner à nouveau la musique Ajustement sans étiquettes Enregistrer l\'état de lecture actuel maintenant - Rétablir l\'état de lecture enregistré précédemment (si il existe) + Rétablir l\'état de lecture enregistré précédemment (s\'il existe) Volume normalisé Le préampli est appliqué à l\'ajustement actuel durant la lecture Enregistrer l\'état de lecture @@ -298,7 +298,7 @@ Chanson Voir Jouer la chanson par elle-même - Image sélectionnée + Image de sélection Trier par Direction Sélection @@ -309,4 +309,27 @@ Aucun album Démo Démos + Liste de lecture importée + Liste de lecture exportée + Ajustement piste ReplayGain + Ajustement album ReplayGain + Impossible d\'exporter la liste de lecture dans ce fichier + Auteur + Impossible d’importer une liste de lecture depuis ce fichier + Liste de lecture vide + Importer la liste de lecture + Chemin + Faire un don + Liste de lecture importée + Soutiens + Faites un don au projet pour que votre nom soit ajouté ici ! + Se souvenir de la pause + Rester en lecture ou en pause en sautant ou en modifiant la file d’attente + Exporter + Exporter la liste de lecture + Importer + Style de chemin + Absolu + Relatif + Utiliser les chemins compatibles Windows \ No newline at end of file diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml index c7aec119c..79cd80ab4 100644 --- a/app/src/main/res/values-ia/strings.xml +++ b/app/src/main/res/values-ia/strings.xml @@ -76,4 +76,130 @@ Usar percursos compatibile con Windows Stato salveguardate A proposito de + Un reproductor de musica simple e rational pro Android. + Genere + Generes + Cercar + Disco + Vader al album + Data de + Taxa de bits + Facer un donation + Selection + Information re le error + Signalar + Autor + Alexander Capehart + Vider e controlar le reproduction de musica + Addite al cauda + Lista de reproduction create + Lista de reproduction importate + Face un donation al projecto pro obtener tu nomine addite hic! + Addite al lista de reproduction + Cerca in le bibliotheca… + Parametros + Apparentia e comportamento + Cambiar le thema e colores del application + Thema + Schema de color + Automatic + Clar + Obscur + Thema nigre + Usar un thema nigre pur + Modo ronde + Personalisar + Personalisar le controlos e comportamento del interfacie de usator + Schermo + Reproducer ab le genere + Schedas del bibliotheca + Saltar al sequente + Comportamento + Al reproducer ab le bibliotheca + Al reproducer ab le detalios de elemento + Reproducer ab le elemento mostrate + Reproducer ab artista + Mantener le reproduction aleatori al reproducer un nove canto + Auxio besonia permission pro leger tu bibliotheca de musica + Genere incognite + Generes cargate: %d + Imagine de genere ab %s + Statisticas del bibliotheca + Bibliotheca + Stato radite + Copiate + Modo de repetition + Reproducer ab tote le cantos + Contento + Musica + Audio + Crear un nove lista de reproduction + Remover iste canto + Copertura de album + Coperrturas de album + Disactivate + Rapide + Reproduction + Rememorar le pausa + Dossieres de musica + Dossieres + Modo + Actualisar le musica + Salveguardar stato de reproduction + Restabilir le stato de reproduction + Nulle musica trovate + Falleva le carga del musica + Necun dossieres + Iste dossier non es supportate + Non poteva salveguardar le stato + Tracia %d + Reproducer o pausar + Saltar al canto sequente + Saltar al ultime canto + Cambiar modo de repetition + Stoppar le reproduction + Aperir le cauda + Remover le dossier + Icone de Auxio + Copertura de album pro %s + Artista incognite + Necun data + Necun disco + Necun tracia + Necun cantos + Necun albumes + Audio MPEG-4 + Audio MPEG-1 + Audio Ogg + Audio Matroska + Free Lossless Audio Codec (FLAC) + Advanced Audio Coding (AAC) + %d seligite + Disco %d + + %d album + %d albumes + + + %d artista + %d artistas + + + %d canto + %d cantos + + Non poteva rader le stato + Non poteva restaurar le stato + Cantos cargate: %d + Albumes cargate: %d + Artistas cargate: %d + Duration total: %s + Aleatori + Pausar in le repetition + Cargamento del musica + Lista de reproduction renominate + Lista de reproduction exportate + Lista de reproduction delite + Recargamento automatic + Imagines \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 53fc707cd..27372f238 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -41,7 +41,7 @@ Codice sorgente Licenze Sviluppato da Alexander Capehart - Statistiche libreria + Statistiche della raccolta Opzioni Aspetto @@ -301,4 +301,31 @@ Visualizza Riproduci brano da solo Ordina per + Playlist importata + Impossibile esportare la playlist in questo file + Direzione + Espandi + Immagine di selezione + Selezione + Copiato + Segnala + Autore + Dona + Supporti + Informazioni sull\'errore + Nessun album + Percorso + Playlist vuota + Importa playlist + Dona al progetto; il tuo nome sarà aggiunto qui! + Impossibile importare una playlist da questo file + Importa + Esporta + Esporta playlist + Stile percorso + Assoluto + Relativo + Usa percorsi compatibili con Windows + Playlist importata + Playlist esportata \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9d7f7f28b..bae32c1d8 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -324,4 +324,10 @@ 내보내기 재생 목록 내보내기 경로 스타일 + 대기열을 건너뛰거나 편집할 때 일시 중지 상태 기억 + 이 파일에서 재생 목록을 가져올 수 없습니다. + 이 파일로 재생 목록을 내보낼 수 없습니다. + ReplayGain 트랙 조절값 + ReplayGain 앨범 조절값 + 일시 중지 기억 \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 56ac63584..4b659230c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -223,7 +223,7 @@ -%.1f dB %d kbps Compilação ao vivo - Compilações de remix + Compilação de remix Mais (+) E comercial (&) Vírgula (,) @@ -238,8 +238,8 @@ Desligado Rápido Alta qualidade - Mixes - Mix + Mixagens de DJ + Mixagem de DJ %d artista %d artistas @@ -299,7 +299,7 @@ Visualizar Playlist importada Playlist exportada - Incapaz de importar uma playlist deste arquivo + Não foi possível importar uma playlist deste arquivo Incapaz de exportar a playlist para este arquivo Demos Autor @@ -320,4 +320,13 @@ Relativo Importar Usar caminhos compatíveis com Windows + Ajuste de ReplayGain do álbum + Informação de erro + Forçar capas de álbuns quadradas + Sem disco + Sem álbuns + Ajuste de ReplayGain da faixa + Recorta todas as capas de álbuns para uma proporção de imagem 1:1 + Sem músicas + Informar \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 80fd5c6b3..42fae303f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -224,7 +224,7 @@ Эквалайзер Скрыть соавторов Показывать только тех исполнителей, которые напрямую указаны в альбоме - Исключить не-музыку + Исключить другие звуковые файлы Многозначные разделители Косая черта (/) Настройка символов, обозначающих несколько значений тегов diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt index dc3182282..d2a68f089 100644 --- a/fastlane/metadata/android/be/full_description.txt +++ b/fastlane/metadata/android/be/full_description.txt @@ -11,6 +11,7 @@ Auxio - гэта мясцовы музычны плэер з хуткім і н - Удасканаленая сістэма выканаўцаў, якая аб'ядноўвае выканаўцаў і выканаўцаў альбомаў - Кіраванне тэчкамі з улікам SD-карты - Надзейнае захаванне стану прайгравання +- Аўтаматычнае ўзнаўленне без разрываў - Поўная падтрымка ReplayGain (для файлаў MP3, FLAC, OGG, OPUS і MP4) - Падтрымка знешняга эквалайзера (напрыклад, Wavelet) - Ад краю да краю diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index caf9e51ef..f47808edf 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -12,6 +12,7 @@ přesná/původní data, štítky pro řazení a další - Správa složek podporující SD karty - Spolehlivá funkce seznamů skladeb - Uchovávání stavu přehrávání +- Automatické přehrávání bez mezer - Plná podpora ReplayGain (u souborů MP3, FLAC, OGG, OPUS a MP4) - Podpora externích přehrávačů (např. Wavelet) - Edge-to-edge diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 3eedd16a2..fc52bfe41 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -12,6 +12,7 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab - untersützt SD-Karten - verlässliche Wiedergabelisten-Verwaltung - verlässliches Speichern des Wiedergabezustands +- automatische lückenlose Wiedergabe - Vollständiger ReplayGain-Support (für MP3-, FLAC-, OGG-, OPUS- und MP4-Dateien) - Externer Equalizerunterstützung (z.B. Wavelet) - Edge-to-Edge diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 67ee646c7..854a23c2f 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -11,6 +11,7 @@ fechas precisas/originales, ordenar etiquetas y más - Sistema de artista avanzado que unifica artistas y artistas de álbumes - Gestión de carpetas compatible con tarjetas SD - Funcionalidad de lista de reproducción confiable +- Reproducción automática sin pausas - Persistencia del estado de reproducción - Compatibilidad total con ReplayGain (en archivos MP3, FLAC, OGG, OPUS y MP4) - Soporte de ecualizador externo (ej. Wavelet) diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 7567e8b1c..aeb864c64 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -1,10 +1,10 @@ -Auxio est un lecteur de musique local doté d'une UI/UX rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une librairie moderne de lecture de media, Auxio supporte une libaririe et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'android dépassées. Pour faire simple, il joue votre musique . +Auxio est un lecteur de musique local doté d'une interface utilisateur rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une bibliothèque moderne de lecture de médias, Auxio prend en charge une bibliothèque et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'Android dépassées. Pour faire simple, il joue votre musique . Fonctionnalités - Lecture basée sur l'ExoPlayer Media3 -- UI réactive dérivée des dernières lignes directrices en Material Design -- UX orientée qui mets l'accent sur la facilité d'utilisation plutôt que sur les usages +- Interface réactive dérivée des dernières lignes directrices en Material Design +- UX orientée qui met l'accent sur la facilité d'utilisation plutôt que sur les usages particuliers - Comportement personnalisable - Reconnaît les numéros de disque, les artistes multiples, les types de support, les dates précises/originales, le classement par tags, and plus encore diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt index 81b86358b..be3a02a95 100644 --- a/fastlane/metadata/android/hi/full_description.txt +++ b/fastlane/metadata/android/hi/full_description.txt @@ -12,6 +12,7 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक - एसडी कार्ड-जागरूक फ़ोल्डर प्रबंधन - विश्वसनीय प्लेलिस्टिंग कार्यक्षमता - प्लेबैक अवस्था दृढ़ता +- स्वचालित गैपलेस प्लेबैक - पूर्ण रीप्लेगैन समर्थन (MP3, FLAC, OGG, OPUS और MP4 फ़ाइलों पर) - बाहरी तुल्यकारक समर्थन (उदा: वेवलेट) - एज-टू-एज diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt index 387682b2c..45f7cc6ca 100644 --- a/fastlane/metadata/android/ko/full_description.txt +++ b/fastlane/metadata/android/ko/full_description.txt @@ -12,6 +12,7 @@ Auxio는 다른 음악 플레이어에 존재하는 쓸모없는 기능 없이, - SD 카드를 지원하는 폴더 관리 기능 - 안정적인 재생 목록 기능 - 이전 재생 상태 기억 +- 자동 갭리스 재생 지원 - ReplayGain 완벽 지원 (MP3, FLAC, OGG, OPUS, MP4) - 외부 이퀄라이저 지원 (Wavelet 등) - Edge-to-edge @@ -20,4 +21,4 @@ Auxio는 다른 음악 플레이어에 존재하는 쓸모없는 기능 없이, - 헤드셋 연결 시 자동 재생 - 크기에 따라 자동으로 조정되는 세련된 위젯 - 인터넷 사용 없음 -- 둥근 앨범 커버 없음 (기본값) +- 둥글지 않은 앨범 커버 (기본값) diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt index c896207aa..646c41be8 100644 --- a/fastlane/metadata/android/pa/full_description.txt +++ b/fastlane/metadata/android/pa/full_description.txt @@ -12,6 +12,7 @@ Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱ - ਭਰੋਸੇਯੋਗ ਪਲੇਅਲਿਸਟਿੰਗ ਕਾਰਜਕੁਸ਼ਲਤਾ - ਭਰੋਸੇਯੋਗ ਪਲੇਅਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ +- ਆਟੋਮੈਟਿਕ ਗੈਪਲੈੱਸ ਪਲੇਅਬੈਕ - ਪੂਰਾ ਰੀਪਲੇਅ-ਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) - ਬਾਹਰੀ ਈਕੋਲਾਈਜ਼ਰ ਦਾ ਸਮਰਥਨ (ਉਦਾਹਰਨ. ਵੇਵਲੇਟ) - ਕਿਨਾਰੇ-ਤੋਂ-ਕਿਨਾਰੇ diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt index 634f1e263..0220f53b7 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/fastlane/metadata/android/ru/full_description.txt @@ -11,6 +11,7 @@ Auxio — это локальный музыкальный плеер с быс - Расширенная система исполнителей, объединяющая исполнителей и исполнителей альбомов - Управление папками на SD-карте - Надёжное сохранение состояния воспроизведения +- Автоматическое воспроизведение без пауз - Полная поддержка ReplayGain (в файлах MP3, FLAC, OGG, OPUS и MP4) - Поддержка внешнего эквалайзера (например, Wavelet) - Дизайн от края до края diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 890722b67..b2bf2642f 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -12,6 +12,7 @@ Auxio – це локальний музичний плеєр зі швидки - Керування теками з підтримкою SD-картки - Надійне функція списків відтворення - Збереження стану відтворення +- Автоматичне відтворення без пропусків - Повна підтримка ReplayGain (у файлах MP3, FLAC, OGG, OPUS і MP4) - Підтримка зовнішнього еквалайзера (наприклад, Wavelet) - Дизайн від краю до краю diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 318031e5c..5eeab94cf 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -12,6 +12,7 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 文件夹管理功能可以感知到 SD 卡 - 可靠的播放列表功能 - 回放状态持久化 +- 自动无缝回放 - 完整的回放增益支持(包括 MP3、FLAC、OGG、OPUS 和 MP4 文件) - 支持外部均衡器(如 Wavelet 这样的应用) - 边到边设计 From 317c83a4d139f7be2e5e2599a6294f275dd77ca6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 25 Mar 2024 13:35:43 -0600 Subject: [PATCH 005/110] info: add paypal to funding An alternative method that might be easier to use than GH sponsors. --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e32f32ffc..7b277dce3 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ github: [OxygenCobalt] +custom: ["https://paypal.me/oxycblt"] From 22a22a883f17cd71203d6506c6591fde5517c789 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 25 Feb 2024 11:26:20 -0700 Subject: [PATCH 006/110] service: unify playback and indexer Playback and indexing now occur in the same service through a new bridge called AuxioService. AuxioService contains the existing service instances as Fragment implementations, and then forwards typical service events to them (albeit this will drift more and more as I continue to deal with lifecycle issues). This should be the first step in enabling true service independence, as it means that the service will now immediately initialize and load music as soon as possible. --- app/src/main/AndroidManifest.xml | 14 +--- .../java/org/oxycblt/auxio/MainActivity.kt | 6 +- .../oxycblt/auxio/music/MusicRepository.kt | 4 +- .../IndexerNotifications.kt | 2 +- .../IndexerServiceFragment.kt} | 66 +++++++--------- .../{system => service}/BetterShuffleOrder.kt | 3 +- .../BluetoothHeadsetReceiver.kt | 2 +- .../MediaButtonReceiver.kt | 7 +- .../MediaSessionComponent.kt | 10 +-- .../NotificationComponent.kt | 16 ++-- .../PlaybackServiceFragment.kt} | 78 +++++++------------ .../{system => service}/SystemModule.kt | 2 +- .../org/oxycblt/auxio/service/AuxioService.kt | 68 ++++++++++++++++ .../auxio/service/ForegroundManager.kt | 78 ------------------- .../oxycblt/auxio/service/ServiceFragment.kt | 69 ++++++++++++++++ .../oxycblt/auxio/widgets/WidgetProvider.kt | 12 +-- build.gradle | 2 +- 17 files changed, 229 insertions(+), 210 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{system => service}/IndexerNotifications.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{system/IndexerService.kt => service/IndexerServiceFragment.kt} (85%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/BetterShuffleOrder.kt (98%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/BluetoothHeadsetReceiver.kt (97%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/MediaButtonReceiver.kt (94%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/MediaSessionComponent.kt (98%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/NotificationComponent.kt (90%) rename app/src/main/java/org/oxycblt/auxio/playback/{system/PlaybackService.kt => service/PlaybackServiceFragment.kt} (93%) rename app/src/main/java/org/oxycblt/auxio/playback/{system => service}/SystemModule.kt (98%) create mode 100644 app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/service/ServiceFragment.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7fa7b198..c657ec84b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -77,22 +77,12 @@ - - diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 3fa2ad852..f997f3b0c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -29,10 +29,9 @@ import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.databinding.ActivityMainBinding -import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.playback.system.PlaybackService +import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD @@ -71,8 +70,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - startService(Intent(this, IndexerService::class.java)) - startService(Intent(this, PlaybackService::class.java)) + startService(Intent(this, AuxioService::class.java)) if (!startIntentAction(intent)) { // No intent action to do, just restore the previously saved state. diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 459c8fcbb..e1b6e2442 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -206,7 +206,7 @@ interface MusicRepository { /** A persistent worker that can load music in the background. */ interface IndexingWorker { /** A [Context] required to read device storage */ - val context: Context + val applicationContext: Context /** The [CoroutineScope] to perform coroutine music loading work on. */ val scope: CoroutineScope @@ -343,7 +343,7 @@ constructor( } override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = - worker.scope.launch { indexWrapper(worker.context, this, withCache) } + worker.scope.launch { indexWrapper(worker.applicationContext, this, withCache) } private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) { try { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index e94e4fe16..c70707375 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.system +package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt similarity index 85% rename from app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt index 83f8d5f80..06fd193e4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * IndexerService.kt is part of Auxio. + * IndexerServiceFragment.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,18 +16,16 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.system +package org.oxycblt.auxio.music.service import android.app.Service -import android.content.Intent +import android.content.Context import android.database.ContentObserver import android.os.Handler -import android.os.IBinder import android.os.Looper import android.os.PowerManager import android.provider.MediaStore import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,7 +37,7 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.service.ForegroundManager +import org.oxycblt.auxio.service.ServiceFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -57,35 +55,33 @@ import org.oxycblt.auxio.util.logD * * TODO: Unify with PlaybackService as part of the service independence project */ -@AndroidEntryPoint -class IndexerService : - Service(), +class IndexerServiceFragment +@Inject +constructor( + val imageLoader: ImageLoader, + val musicRepository: MusicRepository, + val musicSettings: MusicSettings, + val playbackManager: PlaybackStateManager +) : + ServiceFragment(), MusicRepository.IndexingWorker, MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { - @Inject lateinit var imageLoader: ImageLoader - @Inject lateinit var musicRepository: MusicRepository - @Inject lateinit var musicSettings: MusicSettings - @Inject lateinit var playbackManager: PlaybackStateManager - private val serviceJob = Job() private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private var currentIndexJob: Job? = null - private lateinit var foregroundManager: ForegroundManager private lateinit var indexingNotification: IndexingNotification private lateinit var observingNotification: ObservingNotification private lateinit var wakeLock: PowerManager.WakeLock private lateinit var indexerContentObserver: SystemContentObserver - override fun onCreate() { - super.onCreate() - // Initialize the core service components first. - foregroundManager = ForegroundManager(this) - indexingNotification = IndexingNotification(this) - observingNotification = ObservingNotification(this) + override fun onCreate(context: Context) { + indexingNotification = IndexingNotification(context) + observingNotification = ObservingNotification(context) wakeLock = - getSystemServiceCompat(PowerManager::class) + context + .getSystemServiceCompat(PowerManager::class) .newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") // Initialize any listener-dependent components last as we wouldn't want a listener race @@ -99,14 +95,8 @@ class IndexerService : logD("Service created.") } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = START_NOT_STICKY - - override fun onBind(intent: Intent?): IBinder? = null - override fun onDestroy() { - super.onDestroy() // De-initialize core service components first. - foregroundManager.release() wakeLock.releaseSafe() // Then cancel the listener-dependent components to ensure that stray reloading // events will not occur. @@ -126,10 +116,11 @@ class IndexerService : // Cancel the previous music loading job. currentIndexJob?.cancel() // Start a new music loading job on a co-routine. - currentIndexJob = musicRepository.index(this@IndexerService, withCache) + currentIndexJob = musicRepository.index(this, withCache) } - override val context = this + override val applicationContext: Context + get() = context override val scope = indexScope @@ -169,9 +160,9 @@ class IndexerService : // notification when initially starting, we will not update the notification // unless it indicates that it has changed. val changed = indexingNotification.updateIndexingState(progress) - if (!foregroundManager.tryStartForeground(indexingNotification) && changed) { + if (changed) { logD("Notification changed, re-posting notification") - indexingNotification.post() + startForeground(indexingNotification) } // Make sure we can keep the CPU on while loading music wakeLock.acquireSafe() @@ -188,14 +179,11 @@ class IndexerService : // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need // this anymore, or at least I only have to use it when the app task is not removed. logD("Need to observe, staying in foreground") - if (!foregroundManager.tryStartForeground(observingNotification)) { - logD("Notification changed, re-posting notification") - observingNotification.post() - } + startForeground(observingNotification) } else { // Not observing and done loading, exit foreground. logD("Exiting foreground") - foregroundManager.tryStopForeground() + stopForeground() } // Release our wake lock (if we were using it) wakeLock.releaseSafe() @@ -250,7 +238,7 @@ class IndexerService : private val handler = Handler(Looper.getMainLooper()) init { - contentResolverSafe.registerContentObserver( + context.contentResolverSafe.registerContentObserver( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) } @@ -260,7 +248,7 @@ class IndexerService : */ fun release() { handler.removeCallbacks(this) - contentResolverSafe.unregisterContentObserver(this) + context.contentResolverSafe.unregisterContentObserver(this) } override fun onChange(selfChange: Boolean) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt index e09d9ba2a..fe5c8628b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt @@ -16,11 +16,10 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import androidx.media3.common.C import androidx.media3.exoplayer.source.ShuffleOrder -import java.util.* /** * A ShuffleOrder that fixes the poorly defined default implementation of cloneAndInsert. Whereas diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.kt index c8dbabc83..11df166f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/BluetoothHeadsetReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BluetoothHeadsetReceiver.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.bluetooth.BluetoothProfile import android.content.BroadcastReceiver diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt similarity index 94% rename from app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt index 83b6bcbcd..f2281ac49 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.content.BroadcastReceiver import android.content.ComponentName @@ -29,7 +29,8 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD /** - * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService]. + * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to + * [PlaybackServiceFragment]. * * @author Alexander Capehart (OxygenCobalt) */ @@ -46,7 +47,7 @@ class MediaButtonReceiver : BroadcastReceiver() { // wrong action at the wrong time will result in the app crashing, and there is // nothing I can do about it. logD("Delivering media button intent $intent") - intent.component = ComponentName(context, PlaybackService::class.java) + intent.component = ComponentName(context, PlaybackServiceFragment::class.java) ContextCompat.startForegroundService(context, intent) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionComponent.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionComponent.kt index cbccf56ec..61720d277 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionComponent.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.content.Context import android.content.Intent @@ -275,7 +275,7 @@ constructor( override fun onStop() { // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT)) + context.sendBroadcast(Intent(PlaybackServiceFragment.ACTION_EXIT)) } // --- INTERNAL --- @@ -403,7 +403,7 @@ constructor( ActionMode.SHUFFLE -> { logD("Using shuffle MediaSession action") PlaybackStateCompat.CustomAction.Builder( - PlaybackService.ACTION_INVERT_SHUFFLE, + PlaybackServiceFragment.ACTION_INVERT_SHUFFLE, context.getString(R.string.desc_shuffle), if (playbackManager.isShuffled) { R.drawable.ic_shuffle_on_24 @@ -414,7 +414,7 @@ constructor( else -> { logD("Using repeat mode MediaSession action") PlaybackStateCompat.CustomAction.Builder( - PlaybackService.ACTION_INC_REPEAT_MODE, + PlaybackServiceFragment.ACTION_INC_REPEAT_MODE, context.getString(R.string.desc_change_repeat), playbackManager.repeatMode.icon) } @@ -424,7 +424,7 @@ constructor( // Add the exit action so the service can be closed val exitAction = PlaybackStateCompat.CustomAction.Builder( - PlaybackService.ACTION_EXIT, + PlaybackServiceFragment.ACTION_EXIT, context.getString(R.string.desc_exit), R.drawable.ic_close_24) .build() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/NotificationComponent.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/NotificationComponent.kt index 7b9868072..c441f8bff 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/NotificationComponent.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.annotation.SuppressLint import android.content.Context @@ -53,11 +53,13 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes addAction(buildRepeatAction(context, RepeatMode.NONE)) addAction( - buildAction(context, PlaybackService.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24)) + buildAction( + context, PlaybackServiceFragment.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24)) addAction(buildPlayPauseAction(context, true)) addAction( - buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24)) - addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_close_24)) + buildAction( + context, PlaybackServiceFragment.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24)) + addAction(buildAction(context, PlaybackServiceFragment.ACTION_EXIT, R.drawable.ic_close_24)) setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3)) } @@ -122,14 +124,14 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes } else { R.drawable.ic_play_24 } - return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes) + return buildAction(context, PlaybackServiceFragment.ACTION_PLAY_PAUSE, drawableRes) } private fun buildRepeatAction( context: Context, repeatMode: RepeatMode ): NotificationCompat.Action { - return buildAction(context, PlaybackService.ACTION_INC_REPEAT_MODE, repeatMode.icon) + return buildAction(context, PlaybackServiceFragment.ACTION_INC_REPEAT_MODE, repeatMode.icon) } private fun buildShuffleAction( @@ -142,7 +144,7 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes } else { R.drawable.ic_shuffle_off_24 } - return buildAction(context, PlaybackService.ACTION_INVERT_SHUFFLE, drawableRes) + return buildAction(context, PlaybackServiceFragment.ACTION_INVERT_SHUFFLE, drawableRes) } private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) = diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index ba54a1f18..f30d1f727 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2021 Auxio Project - * PlaybackService.kt is part of Auxio. + * PlaybackServiceFragment.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,16 +16,14 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service -import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.media.audiofx.AudioEffect -import android.os.IBinder import androidx.core.content.ContextCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -39,7 +37,6 @@ import androidx.media3.exoplayer.audio.AudioCapabilities import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.MediaSource -import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -63,7 +60,7 @@ import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RawQueue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.StateAck -import org.oxycblt.auxio.service.ForegroundManager +import org.oxycblt.auxio.service.ServiceFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.widgets.WidgetComponent @@ -85,9 +82,20 @@ import org.oxycblt.auxio.widgets.WidgetProvider * TODO: Refactor lifecycle to run completely headless (i.e no activity needed) * TODO: Android Auto */ -@AndroidEntryPoint -class PlaybackService : - Service(), +class PlaybackServiceFragment +@Inject +constructor( + val mediaSourceFactory: MediaSource.Factory, + val replayGainProcessor: ReplayGainAudioProcessor, + val mediaSessionComponent: MediaSessionComponent, + val widgetComponent: WidgetComponent, + val playbackManager: PlaybackStateManager, + val playbackSettings: PlaybackSettings, + val persistenceRepository: PersistenceRepository, + val listSettings: ListSettings, + val musicRepository: MusicRepository +) : + ServiceFragment(), Player.Listener, PlaybackStateHolder, PlaybackSettings.Listener, @@ -95,23 +103,11 @@ class PlaybackService : MusicRepository.UpdateListener { // Player components private lateinit var player: ExoPlayer - @Inject lateinit var mediaSourceFactory: MediaSource.Factory - @Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor // System backend components - @Inject lateinit var mediaSessionComponent: MediaSessionComponent - @Inject lateinit var widgetComponent: WidgetComponent private val systemReceiver = PlaybackReceiver() - // Shared components - @Inject lateinit var playbackManager: PlaybackStateManager - @Inject lateinit var playbackSettings: PlaybackSettings - @Inject lateinit var persistenceRepository: PersistenceRepository - @Inject lateinit var listSettings: ListSettings - @Inject lateinit var musicRepository: MusicRepository - - // State - private lateinit var foregroundManager: ForegroundManager + // Stat private var hasPlayed = false private var openAudioEffectSession = false @@ -123,16 +119,14 @@ class PlaybackService : // --- SERVICE OVERRIDES --- - override fun onCreate() { - super.onCreate() - + override fun onCreate(context: Context) { // Since Auxio is a music player, only specify an audio renderer to save // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), MediaCodecAudioRenderer( - this, + context, MediaCodecSelector.DEFAULT, handler, audioListener, @@ -141,7 +135,7 @@ class PlaybackService : } player = - ExoPlayer.Builder(this, audioRenderer) + ExoPlayer.Builder(context, audioRenderer) .setMediaSourceFactory(mediaSourceFactory) // Enable automatic WakeLock support .setWakeMode(C.WAKE_MODE_LOCAL) @@ -154,7 +148,6 @@ class PlaybackService : true) .build() .also { it.addListener(this) } - foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. playbackManager.registerStateHolder(this) @@ -176,23 +169,19 @@ class PlaybackService : } ContextCompat.registerReceiver( - this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) + context, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) logD("Service created") } - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + override fun onStartCommand(intent: Intent) { // Forward system media button sent by MediaButtonReceiver to MediaSessionComponent if (intent.action == Intent.ACTION_MEDIA_BUTTON) { mediaSessionComponent.handleMediaButtonIntent(intent) } - return START_NOT_STICKY } - override fun onBind(intent: Intent): IBinder? = null - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) + override fun onTaskRemoved() { if (!playbackManager.progression.isPlaying) { playbackManager.playing(false) endSession() @@ -200,17 +189,13 @@ class PlaybackService : } override fun onDestroy() { - super.onDestroy() - - foregroundManager.release() - // Pause just in case this destruction was unexpected. playbackManager.playing(false) playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) playbackSettings.unregisterListener(this) - unregisterReceiver(systemReceiver) + context.unregisterReceiver(systemReceiver) serviceJob.cancel() widgetComponent.release() @@ -454,7 +439,7 @@ class PlaybackService : // Open -> Try to find the Song for the given file and then play it from all songs is DeferredPlayback.Open -> { logD("Opening specified file") - deviceLibrary.findSongForUri(application, action.uri)?.let { song -> + deviceLibrary.findSongForUri(context.applicationContext, action.uri)?.let { song -> playbackManager.play( song, null, @@ -579,10 +564,7 @@ class PlaybackService : // manner. if (hasPlayed) { logD("Played before, starting foreground state") - if (!foregroundManager.tryStartForeground(notification)) { - logD("Notification changed, re-posting") - notification.post() - } + startForeground(notification) } } @@ -659,9 +641,9 @@ class PlaybackService : private fun broadcastAudioEffectAction(event: String) { logD("Broadcasting AudioEffect event: $event") - sendBroadcast( + context.sendBroadcast( Intent(event) - .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) } @@ -678,7 +660,7 @@ class PlaybackService : if (!player.isPlaying) { hasPlayed = false playbackManager.playing(false) - foregroundManager.tryStopForeground() + stopForeground() } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.kt index 47b052761..3aade8f3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemModule.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.content.Context import androidx.media3.datasource.ContentDataSource diff --git a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt new file mode 100644 index 000000000..eb782fca4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Auxio Project + * AuxioService.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.service + +import android.app.Service +import android.content.Intent +import androidx.core.app.ServiceCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import org.oxycblt.auxio.music.service.IndexerServiceFragment +import org.oxycblt.auxio.playback.service.PlaybackServiceFragment + +@AndroidEntryPoint +class AuxioService : Service() { + @Inject lateinit var playbackFragment: PlaybackServiceFragment + @Inject lateinit var indexerFragment: IndexerServiceFragment + + override fun onBind(intent: Intent?) = null + + override fun onCreate() { + super.onCreate() + playbackFragment.attach(this) + indexerFragment.attach(this) + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + playbackFragment.handleIntent(intent) + indexerFragment.handleIntent(intent) + return START_NOT_STICKY + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + playbackFragment.handleTaskRemoved() + indexerFragment.handleTaskRemoved() + } + + override fun onDestroy() { + super.onDestroy() + playbackFragment.release() + indexerFragment.release() + } + + fun refreshForeground() { + val currentNotification = playbackFragment.notification ?: indexerFragment.notification + if (currentNotification != null) { + startForeground(currentNotification.code, currentNotification.build()) + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt b/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt deleted file mode 100644 index b23457d48..000000000 --- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundManager.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ForegroundManager.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.service - -import android.app.Service -import androidx.core.app.ServiceCompat -import org.oxycblt.auxio.util.logD - -/** - * A utility to create consistent foreground behavior for a given [Service]. - * - * @param service [Service] to wrap in this instance. - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Merge with unified service when done. - */ -class ForegroundManager(private val service: Service) { - private var isForeground = false - - /** Release this instance. */ - fun release() { - tryStopForeground() - } - - /** - * Try to enter a foreground state. - * - * @param notification The [ForegroundServiceNotification] to show in order to signal the - * foreground state. - * @return true if the state was changed, false otherwise - * @see Service.startForeground - */ - fun tryStartForeground(notification: ForegroundServiceNotification): Boolean { - if (isForeground) { - // Nothing to do. - return false - } - - logD("Starting foreground state") - service.startForeground(notification.code, notification.build()) - isForeground = true - return true - } - - /** - * Try to exit a foreground state. Will remove the foreground notification. - * - * @return true if the state was changed, false otherwise - * @see Service.stopForeground - */ - fun tryStopForeground(): Boolean { - if (!isForeground) { - // Nothing to do. - return false - } - - logD("Stopping foreground state") - ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) - isForeground = false - return true - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/service/ServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/service/ServiceFragment.kt new file mode 100644 index 000000000..b52adf629 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/service/ServiceFragment.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 Auxio Project + * ServiceFragment.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.service + +import android.content.Context +import android.content.Intent + +abstract class ServiceFragment { + private var handle: AuxioService? = null + + protected val context: Context + get() = requireNotNull(handle) + + var notification: ForegroundServiceNotification? = null + private set + + fun attach(handle: AuxioService) { + this.handle = handle + onCreate(handle) + } + + fun release() { + notification = null + handle = null + onDestroy() + } + + fun handleIntent(intent: Intent) { + onStartCommand(intent) + } + + fun handleTaskRemoved() { + onTaskRemoved() + } + + protected open fun onCreate(context: Context) {} + + protected open fun onDestroy() {} + + protected open fun onStartCommand(intent: Intent) {} + + protected open fun onTaskRemoved() {} + + protected fun startForeground(notification: ForegroundServiceNotification) { + this.notification = notification + requireNotNull(handle).refreshForeground() + } + + protected fun stopForeground() { + this.notification = null + requireNotNull(handle).refreshForeground() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 7a3bc6c40..3f14578f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.system.PlaybackService +import org.oxycblt.auxio.playback.service.PlaybackServiceFragment import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -323,7 +323,7 @@ class WidgetProvider : AppWidgetProvider() { // by PlaybackService. setOnClickPendingIntent( R.id.widget_play_pause, - context.newBroadcastPendingIntent(PlaybackService.ACTION_PLAY_PAUSE)) + context.newBroadcastPendingIntent(PlaybackServiceFragment.ACTION_PLAY_PAUSE)) // Set up the play/pause button appearance. Like the Android 13 media controls, use // a circular FAB when paused, and a squircle FAB when playing. This does require us @@ -364,10 +364,10 @@ class WidgetProvider : AppWidgetProvider() { // by PlaybackService. setOnClickPendingIntent( R.id.widget_skip_prev, - context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_PREV)) + context.newBroadcastPendingIntent(PlaybackServiceFragment.ACTION_SKIP_PREV)) setOnClickPendingIntent( R.id.widget_skip_next, - context.newBroadcastPendingIntent(PlaybackService.ACTION_SKIP_NEXT)) + context.newBroadcastPendingIntent(PlaybackServiceFragment.ACTION_SKIP_NEXT)) return this } @@ -389,10 +389,10 @@ class WidgetProvider : AppWidgetProvider() { // be recognized by PlaybackService. setOnClickPendingIntent( R.id.widget_repeat, - context.newBroadcastPendingIntent(PlaybackService.ACTION_INC_REPEAT_MODE)) + context.newBroadcastPendingIntent(PlaybackServiceFragment.ACTION_INC_REPEAT_MODE)) setOnClickPendingIntent( R.id.widget_shuffle, - context.newBroadcastPendingIntent(PlaybackService.ACTION_INVERT_SHUFFLE)) + context.newBroadcastPendingIntent(PlaybackServiceFragment.ACTION_INVERT_SHUFFLE)) // Set up the repeat/shuffle buttons. When working with RemoteViews, we will // need to hard-code different accent tinting configurations, as stateful drawables diff --git a/build.gradle b/build.gradle index fdb033f43..eae63966b 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version '8.2.0' apply false + id "com.android.application" version '8.2.1' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false From 86b7ef8d5c9f7c055bca51f9cb035c8fe79f7c2b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Feb 2024 15:26:23 -0700 Subject: [PATCH 007/110] music: fix crash on playlist add Caused by the new state restoration code being bugged and applying on playlist changes, then combined with the playlist code not properly switching to the main context when dispatching a library update. --- .../java/org/oxycblt/auxio/music/MusicRepository.kt | 10 +++++----- .../auxio/playback/state/PlaybackStateManager.kt | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index e1b6e2442..4ae1d962f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -305,35 +305,35 @@ constructor( val userLibrary = synchronized(this) { userLibrary ?: return } logD("Creating playlist $name with ${songs.size} songs") userLibrary.createPlaylist(name, songs) - dispatchLibraryChange(device = false, user = true) + withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } } override suspend fun renamePlaylist(playlist: Playlist, name: String) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Renaming $playlist to $name") userLibrary.renamePlaylist(playlist, name) - dispatchLibraryChange(device = false, user = true) + withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } } override suspend fun deletePlaylist(playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Deleting $playlist") userLibrary.deletePlaylist(playlist) - dispatchLibraryChange(device = false, user = true) + withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } } override suspend fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Adding ${songs.size} songs to $playlist") userLibrary.addToPlaylist(playlist, songs) - dispatchLibraryChange(device = false, user = true) + withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } } override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Rewriting $playlist with ${songs.size} songs") userLibrary.rewritePlaylist(playlist, songs) - dispatchLibraryChange(device = false, user = true) + withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } } @Synchronized diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 7498c8e0b..315642fd0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -780,14 +780,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val oldStateMirror = stateMirror if (oldStateMirror.rawQueue != rawQueue) { logD("Queue changed, must reload player") + stateHolder?.playing(false) stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback) - stateHolder?.playing(false) - } - - if (oldStateMirror.progression.calculateElapsedPositionMs() != savedState.positionMs) { - logD("Seeking to saved position ${savedState.positionMs}ms") stateHolder?.seekTo(savedState.positionMs) - stateHolder?.playing(false) } } From b6f89de88d8e164461dbf36813b6c1ac2d680b95 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Feb 2024 16:08:44 -0700 Subject: [PATCH 008/110] playback: fix crash w/add to queue Again, a two-fold problem: - Was not properly giving the right StateAck to the state holder - ShuffleOrder not properly handling the index given when adding to queue internally Resolves #727. --- .../oxycblt/auxio/playback/service/BetterShuffleOrder.kt | 6 +++++- .../oxycblt/auxio/playback/state/PlaybackStateManager.kt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt index fe5c8628b..ed3044d45 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt @@ -68,7 +68,11 @@ class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { } val newShuffled = IntArray(shuffled.size + insertionCount) - val pivot = indexInShuffled[insertionIndex] + val pivot: Int = if (insertionIndex < shuffled.size) { + indexInShuffled[insertionIndex] + } else { + indexInShuffled.size + } for (i in shuffled.indices) { var currentIndex = shuffled[i] if (currentIndex > insertionIndex) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 315642fd0..6e98c36e3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } else { val stateHolder = stateHolder ?: return logD("Adding ${songs.size} songs to end of queue") - stateHolder.addToQueue(songs, StateAck.AddToQueue(stateMirror.index + 1, songs.size)) + stateHolder.addToQueue(songs, StateAck.AddToQueue(queue.size, songs.size)) } } From dbfe9927bf79392224ae57a1d8977d97acea9e4a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Feb 2024 16:41:31 -0700 Subject: [PATCH 009/110] playback: fix broken state restore That didn't properly handle when the index was invalid and kept stale song entries. Resolves #723. --- .../service/PlaybackServiceFragment.kt | 4 +++ .../playback/state/PlaybackStateHolder.kt | 3 +++ .../playback/state/PlaybackStateManager.kt | 27 +++++++++++-------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index f30d1f727..8b06a8a78 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -470,6 +470,10 @@ constructor( ack?.let { playbackManager.ack(this, it) } } + override fun reset() { + player.setMediaItems(emptyList()) + } + // --- PLAYER OVERRIDES --- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 2374a421f..3cf5f44da 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -153,6 +153,9 @@ interface PlaybackStateHolder { * ack. */ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) + + /** Reset this instance to an empty state. */ + fun reset() } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 6e98c36e3..4dd7bb690 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -717,6 +717,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { return } + val stateHolder = stateHolder ?: return + // The heap may not be the same if the song composition changed between state saves/reloads. // This also means that we must modify the shuffled mapping as well, in what it points to // and it's general composition. @@ -763,27 +765,30 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" } + if (index < 0) { + stateHolder.reset() + return + } + val rawQueue = RawQueue( heap = heap, shuffledMapping = shuffledMapping, heapIndex = if (shuffledMapping.isNotEmpty()) { - shuffledMapping[savedState.index] + shuffledMapping[index] } else { index }) - if (index > -1) { - // Valid state where something needs to be played, direct the stateholder to apply - // this new state. - val oldStateMirror = stateMirror - if (oldStateMirror.rawQueue != rawQueue) { - logD("Queue changed, must reload player") - stateHolder?.playing(false) - stateHolder?.applySavedState(parent, rawQueue, StateAck.NewPlayback) - stateHolder?.seekTo(savedState.positionMs) - } + // Valid state where something needs to be played, direct the stateholder to apply + // this new state. + val oldStateMirror = stateMirror + if (oldStateMirror.rawQueue != rawQueue) { + logD("Queue changed, must reload player") + stateHolder.playing(false) + stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback) + stateHolder.seekTo(savedState.positionMs) } isInitialized = true From 3ca9b515cf098b0d66e344432f23cdc63fa996ec Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 21:50:59 -0700 Subject: [PATCH 010/110] widget: fix wafer cover setup Apparently: 1. Some OEMs don't actually autocrop to rounded corners 2. I was not correctly using the right corner radius attributes in the first place, making it inconsistent. Let's fix that. Closes #730 --- .../oxycblt/auxio/widgets/WidgetProvider.kt | 22 ++++++++++++++++--- ..._bar_system.xml => ui_widget_bg_round.xml} | 2 +- .../main/res/drawable/ui_widget_bar_round.xml | 6 ----- ..._bar_system.xml => ui_widget_bg_sharp.xml} | 0 .../main/res/drawable/ui_widget_bg_system.xml | 5 ----- .../main/res/layout/widget_docked_thin.xml | 2 +- .../main/res/layout/widget_docked_wide.xml | 2 +- app/src/main/res/layout/widget_pane_thin.xml | 2 +- app/src/main/res/layout/widget_pane_wide.xml | 2 +- app/src/main/res/layout/widget_wafer_thin.xml | 4 +++- app/src/main/res/layout/widget_wafer_wide.xml | 9 ++++---- 11 files changed, 32 insertions(+), 24 deletions(-) rename app/src/main/res/drawable-v31/{ui_widget_bar_system.xml => ui_widget_bg_round.xml} (100%) delete mode 100644 app/src/main/res/drawable/ui_widget_bar_round.xml rename app/src/main/res/drawable/{ui_widget_bar_system.xml => ui_widget_bg_sharp.xml} (100%) delete mode 100644 app/src/main/res/drawable/ui_widget_bg_system.xml diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 3f14578f6..388dbdc28 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -158,6 +158,7 @@ class WidgetProvider : AppWidgetProvider() { uiSettings, ) .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) }) + .setupFillingCover(uiSettings) .setupTimelineControls(context, state) private fun newWideWaferLayout( @@ -170,6 +171,7 @@ class WidgetProvider : AppWidgetProvider() { uiSettings, ) .setupCover(context, state.takeIf { canDisplayWaferCover(uiSettings) }) + .setupFillingCover(uiSettings) .setupFullControls(context, state) private fun newThinDockedLayout( @@ -231,9 +233,9 @@ class WidgetProvider : AppWidgetProvider() { // On API 31+, the bar should always be round in order to fit in with other widgets. val background = if (useRoundedRemoteViews(uiSettings)) { - R.drawable.ui_widget_bar_round + R.drawable.ui_widget_bg_round } else { - R.drawable.ui_widget_bar_system + R.drawable.ui_widget_bg_sharp } setBackgroundResource(R.id.widget_controls, background) return this @@ -253,7 +255,7 @@ class WidgetProvider : AppWidgetProvider() { if (useRoundedRemoteViews(uiSettings)) { R.drawable.ui_widget_bg_round } else { - R.drawable.ui_widget_bg_system + R.drawable.ui_widget_bg_sharp } setBackgroundResource(android.R.id.background, background) return this @@ -292,6 +294,20 @@ class WidgetProvider : AppWidgetProvider() { return this } + private fun RemoteViews.setupFillingCover(uiSettings: UISettings): RemoteViews { + // Below API 31, enable a rounded background only if round mode is enabled. + // On API 31+, the background should always be round in order to fit in with other + // widgets. + val background = + if (useRoundedRemoteViews(uiSettings)) { + R.drawable.ui_widget_bg_round + } else { + R.drawable.ui_widget_bg_sharp + } + setBackgroundResource(R.id.widget_cover, background) + return this + } + /** * Set up the album cover, song title, and artist name in a [RemoteViews] layout that contains * them. diff --git a/app/src/main/res/drawable-v31/ui_widget_bar_system.xml b/app/src/main/res/drawable-v31/ui_widget_bg_round.xml similarity index 100% rename from app/src/main/res/drawable-v31/ui_widget_bar_system.xml rename to app/src/main/res/drawable-v31/ui_widget_bg_round.xml index b6144dd5f..893bae085 100644 --- a/app/src/main/res/drawable-v31/ui_widget_bar_system.xml +++ b/app/src/main/res/drawable-v31/ui_widget_bg_round.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/ui_widget_bar_round.xml b/app/src/main/res/drawable/ui_widget_bar_round.xml deleted file mode 100644 index 9fcd8308b..000000000 --- a/app/src/main/res/drawable/ui_widget_bar_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ui_widget_bar_system.xml b/app/src/main/res/drawable/ui_widget_bg_sharp.xml similarity index 100% rename from app/src/main/res/drawable/ui_widget_bar_system.xml rename to app/src/main/res/drawable/ui_widget_bg_sharp.xml diff --git a/app/src/main/res/drawable/ui_widget_bg_system.xml b/app/src/main/res/drawable/ui_widget_bg_system.xml deleted file mode 100644 index 9d33d5912..000000000 --- a/app/src/main/res/drawable/ui_widget_bg_system.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/widget_docked_thin.xml b/app/src/main/res/layout/widget_docked_thin.xml index 2b2566812..086013dcc 100644 --- a/app/src/main/res/layout/widget_docked_thin.xml +++ b/app/src/main/res/layout/widget_docked_thin.xml @@ -61,7 +61,7 @@ android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_gravity="center" - android:background="@drawable/ui_widget_bar_system" + android:background="@drawable/ui_widget_bg_round" android:backgroundTint="?attr/colorSurface" android:orientation="horizontal" android:padding="@dimen/spacing_mid_medium"> diff --git a/app/src/main/res/layout/widget_docked_wide.xml b/app/src/main/res/layout/widget_docked_wide.xml index f0b129ae5..d344799f4 100644 --- a/app/src/main/res/layout/widget_docked_wide.xml +++ b/app/src/main/res/layout/widget_docked_wide.xml @@ -48,7 +48,7 @@ android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_gravity="center" - android:background="@drawable/ui_widget_bar_system" + android:background="@drawable/ui_widget_bg_round" android:backgroundTint="?attr/colorSurface" android:orientation="horizontal" android:padding="@dimen/spacing_mid_medium"> diff --git a/app/src/main/res/layout/widget_pane_thin.xml b/app/src/main/res/layout/widget_pane_thin.xml index 4dc4c394c..2b2361971 100644 --- a/app/src/main/res/layout/widget_pane_thin.xml +++ b/app/src/main/res/layout/widget_pane_thin.xml @@ -4,7 +4,7 @@ android:id="@android:id/background" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/ui_widget_bg_system" + android:background="@drawable/ui_widget_bg_sharp" android:backgroundTint="?attr/colorSurface" android:theme="@style/Theme.Auxio.Widget"> diff --git a/app/src/main/res/layout/widget_pane_wide.xml b/app/src/main/res/layout/widget_pane_wide.xml index 2ef525fa3..07c693564 100644 --- a/app/src/main/res/layout/widget_pane_wide.xml +++ b/app/src/main/res/layout/widget_pane_wide.xml @@ -4,7 +4,7 @@ android:id="@android:id/background" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/ui_widget_bg_system" + android:background="@drawable/ui_widget_bg_sharp" android:backgroundTint="?attr/colorSurface" android:theme="@style/Theme.Auxio.Widget"> diff --git a/app/src/main/res/layout/widget_wafer_thin.xml b/app/src/main/res/layout/widget_wafer_thin.xml index fe7ec01dc..db12288cf 100644 --- a/app/src/main/res/layout/widget_wafer_thin.xml +++ b/app/src/main/res/layout/widget_wafer_thin.xml @@ -4,7 +4,7 @@ android:id="@android:id/background" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/ui_widget_bg_system" + android:background="@drawable/ui_widget_bg_sharp" android:backgroundTint="?attr/colorSurface" android:baselineAligned="false" android:orientation="horizontal" @@ -20,6 +20,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" + android:background="@drawable/ui_widget_bg_round" + android:clipToOutline="true" tools:ignore="ContentDescription" /> - Date: Wed, 28 Feb 2024 22:27:25 -0700 Subject: [PATCH 011/110] all: reformat --- .../auxio/playback/service/BetterShuffleOrder.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt index ed3044d45..5c5415ed5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt @@ -68,11 +68,12 @@ class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { } val newShuffled = IntArray(shuffled.size + insertionCount) - val pivot: Int = if (insertionIndex < shuffled.size) { - indexInShuffled[insertionIndex] - } else { - indexInShuffled.size - } + val pivot: Int = + if (insertionIndex < shuffled.size) { + indexInShuffled[insertionIndex] + } else { + indexInShuffled.size + } for (i in shuffled.indices) { var currentIndex = shuffled[i] if (currentIndex > insertionIndex) { From 2f36fcfb457fa59bd91efbb35d2444386f955a13 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 22:46:43 -0700 Subject: [PATCH 012/110] build: bump to 3.4.2 Bump to version 3.4.2. --- CHANGELOG.md | 7 +++++++ README.md | 4 ++-- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/43.txt | 3 +++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/43.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 186558c62..987d7b716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.4.2 + +#### What's Fixed +- Fixed "Add to queue" incorrectly changing the queue and crashing the app +- Fixed 1x4 and 1x3 widgets having square edges +- Fixed crash when music library updates in such a way to change music information + ## 3.4.1 #### What's Fixed diff --git a/README.md b/README.md index 85006d016..03010bdbe 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index d17211886..868e7d8f3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.4.1" - versionCode 42 + versionName "3.4.2" + versionCode 43 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/43.txt b/fastlane/metadata/android/en-US/changelogs/43.txt new file mode 100644 index 000000000..c57d2b2b2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/43.txt @@ -0,0 +1,3 @@ +Auxio 3.4.0 adds gapless playback and new widget designs, alongside a variety of fixes. +This release fixes critical issues identified in the previous version. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.4.2 \ No newline at end of file From 2a0624f8608ef2e71ecf1dbe324301bba5793a77 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 22:58:35 -0700 Subject: [PATCH 013/110] playback: fix more state restore issues They just keep coming. I hate how complicated this system is. --- .../auxio/playback/service/PlaybackServiceFragment.kt | 3 ++- .../auxio/playback/state/PlaybackStateHolder.kt | 2 +- .../auxio/playback/state/PlaybackStateManager.kt | 11 ++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 8b06a8a78..32e60cf7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -470,8 +470,9 @@ constructor( ack?.let { playbackManager.ack(this, it) } } - override fun reset() { + override fun reset(ack: StateAck.NewPlayback) { player.setMediaItems(emptyList()) + playbackManager.ack(this, ack) } // --- PLAYER OVERRIDES --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 3cf5f44da..259d5ab97 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -155,7 +155,7 @@ interface PlaybackStateHolder { fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) /** Reset this instance to an empty state. */ - fun reset() + fun reset(ack: StateAck.NewPlayback) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 4dd7bb690..fceb344a5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -743,19 +743,20 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } // Make sure we re-align the index to point to the previously playing song. - fun pointingAtSong(): Boolean { + fun pointingAtSong(index: Int): Boolean { val currentSong = if (shuffledMapping.isNotEmpty()) { - shuffledMapping.getOrNull(savedState.index)?.let { heap.getOrNull(it) } + shuffledMapping.getOrNull(index)?.let { heap.getOrNull(it) } } else { - heap.getOrNull(savedState.index) + heap.getOrNull(index) } + logD(currentSong) return currentSong?.uid == savedState.songUid } var index = savedState.index - while (!pointingAtSong() && index > -1) { + while (!pointingAtSong(index) && index > -1) { index-- } @@ -766,7 +767,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } if (index < 0) { - stateHolder.reset() + stateHolder.reset(StateAck.NewPlayback) return } From f5bc31a00ff18f22ef73eb74cb7ebe05ececee3f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 22:59:51 -0700 Subject: [PATCH 014/110] home: fix crash on music updates --- CHANGELOG.md | 1 + .../main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt | 2 +- .../main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt | 2 +- .../main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt | 2 +- .../java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt | 2 +- .../main/java/org/oxycblt/auxio/home/list/SongListFragment.kt | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 987d7b716..cb8325662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed "Add to queue" incorrectly changing the queue and crashing the app - Fixed 1x4 and 1x3 widgets having square edges - Fixed crash when music library updates in such a way to change music information +- Fixed crash when music library updates while scrolled in a list ## 3.4.1 diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 74c942dae..a3ad98835 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -95,7 +95,7 @@ class AlbumListFragment : } override fun getPopup(pos: Int): String? { - val album = homeModel.albumList.value[pos] + val album = homeModel.albumList.value.getOrNull(pos) ?: return null // Change how we display the popup depending on the current sort mode. return when (homeModel.albumSort.mode) { // By Name -> Use Name diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 7dc885308..0f99cdb48 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -90,7 +90,7 @@ class ArtistListFragment : } override fun getPopup(pos: Int): String? { - val artist = homeModel.artistList.value[pos] + val artist = homeModel.artistList.value.getOrNull(pos) ?: return null // Change how we display the popup depending on the current sort mode. return when (homeModel.artistSort.mode) { // By Name -> Use Name diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index 3307fa721..ef001d36e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -89,7 +89,7 @@ class GenreListFragment : } override fun getPopup(pos: Int): String? { - val genre = homeModel.genreList.value[pos] + val genre = homeModel.genreList.value.getOrNull(pos) ?: return null // Change how we display the popup depending on the current sort mode. return when (homeModel.genreSort.mode) { // By Name -> Use Name diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 4228c872a..e0fe15bec 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -87,7 +87,7 @@ class PlaylistListFragment : } override fun getPopup(pos: Int): String? { - val playlist = homeModel.playlistList.value[pos] + val playlist = homeModel.playlistList.value.getOrNull(pos) ?: return null // Change how we display the popup depending on the current sort mode. return when (homeModel.playlistSort.mode) { // By Name -> Use Name diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 04f9847f1..6a5d91bcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -92,7 +92,7 @@ class SongListFragment : } override fun getPopup(pos: Int): String? { - val song = homeModel.songList.value[pos] + val song = homeModel.songList.value.getOrNull(pos) ?: return null // Change how we display the popup depending on the current sort mode. // Note: We don't use the more correct individual artist name here, as sorts are largely // based off the names of the parent objects and not the child objects. From 8221e98401213ce9f3d70d461859a0523d7667a5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 23:08:09 -0700 Subject: [PATCH 015/110] playback: fix add to queue again --- .../auxio/playback/service/BetterShuffleOrder.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt index 5c5415ed5..0f037a75a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/BetterShuffleOrder.kt @@ -67,6 +67,9 @@ class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { return BetterShuffleOrder(insertionCount, -1) } + // TODO: Fix this scuffed hacky logic + // TODO: Play next ordering needs to persist in unshuffle + val newShuffled = IntArray(shuffled.size + insertionCount) val pivot: Int = if (insertionIndex < shuffled.size) { @@ -86,8 +89,14 @@ class BetterShuffleOrder(private val shuffled: IntArray) : ShuffleOrder { newShuffled[i + insertionCount] = currentIndex } } - for (i in 0 until insertionCount) { - newShuffled[pivot + i + 1] = insertionIndex + i + 1 + if (insertionIndex < shuffled.size) { + for (i in 0 until insertionCount) { + newShuffled[pivot + i + 1] = insertionIndex + i + 1 + } + } else { + for (i in 0 until insertionCount) { + newShuffled[pivot + i] = insertionIndex + i + } } return BetterShuffleOrder(newShuffled) } From 261edf6c654459b851434779ed5ee843d7f74329 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Feb 2024 23:11:44 -0700 Subject: [PATCH 016/110] info: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb8325662..74f7b60b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed 1x4 and 1x3 widgets having square edges - Fixed crash when music library updates in such a way to change music information - Fixed crash when music library updates while scrolled in a list +- Fixed inconsistent corner radius in wafer widgets ## 3.4.1 From e0352a105a0897e83bcb6c47b418cb73a6ad5d3e Mon Sep 17 00:00:00 2001 From: unrenowned <163776820+unrenowned@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:27:18 +0000 Subject: [PATCH 017/110] playback: fix playNext crash on last song of queue Fixes OxygenCobalt/Auxio#735. ExoPlayer method for fetching next media item returns C.INDEX_UNSET (-1) when used on the last song of a queue, which is not a valid index for ExoPlayer.addMediaItems(). New code just adds songs to the end of the queue if there isn't a next song. --- .../auxio/playback/service/PlaybackServiceFragment.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 32e60cf7d..ee2d281ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -362,7 +362,14 @@ constructor( } override fun playNext(songs: List, ack: StateAck.PlayNext) { - player.addMediaItems(player.nextMediaItemIndex, songs.map { it.toMediaItem() }) + val nextIndex = player.nextMediaItemIndex + + if (nextIndex == C.INDEX_UNSET) { + player.addMediaItems(songs.map { it.toMediaItem() }) + } else { + player.addMediaItems(nextIndex, songs.map { it.toMediaItem() }) + } + playbackManager.ack(this, ack) deferSave() } From b075f8ec517ef0a28480c763e36d9db85b07ddbe Mon Sep 17 00:00:00 2001 From: unrenowned <163776820+unrenowned@users.noreply.github.com> Date: Fri, 29 Mar 2024 14:40:42 +0000 Subject: [PATCH 018/110] playback: fix playNext wraparound with Repeat All ExoPlayer method for fetching next media item respects Repeat All, which on the last song of a queue causes playNext to wrap around and insert the songs at the start of the queue. New code fetches next song as if repeat were turned off, so the songs will always be added to the end of the queue. --- .../playback/service/PlaybackServiceFragment.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index ee2d281ed..d3d41f61e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -362,7 +362,17 @@ constructor( } override fun playNext(songs: List, ack: StateAck.PlayNext) { - val nextIndex = player.nextMediaItemIndex + val currTimeline = player.currentTimeline + val nextIndex = + if (currTimeline.isEmpty) { + C.INDEX_UNSET + } else { + currTimeline.getNextWindowIndex( + player.currentMediaItemIndex, + Player.REPEAT_MODE_OFF, + player.shuffleModeEnabled + ) + } if (nextIndex == C.INDEX_UNSET) { player.addMediaItems(songs.map { it.toMediaItem() }) From a920da3fbd9879f40974f1dcae0085a9d9696c48 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 4 Apr 2024 11:33:40 -0600 Subject: [PATCH 019/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 2cfefb8f3..f9a17d51d 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 2cfefb8f39d84412920d17be4ba76ebaabf2d6a6 +Subproject commit f9a17d51d6b711cf29823fe543091e647d9f83fc From dc51c84c54c4b429795a2fd0cacd405576cb2803 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 4 Apr 2024 12:11:23 -0600 Subject: [PATCH 020/110] home: handle playback decision event Forgot to add this. Resolves #734. --- CHANGELOG.md | 7 ++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 22 ++++++++++++++++--- .../playback/decision/PlayFromArtistDialog.kt | 2 +- .../playback/decision/PlayFromGenreDialog.kt | 2 +- .../service/PlaybackServiceFragment.kt | 5 +---- app/src/main/res/navigation/inner.xml | 4 ++-- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f7b60b9..a9a877142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## dev + +#### What's Fixed +- Fixed crash when using play next on the end of a queue or with a single-song queue +- Fixed weird behavior if using play next on the end of a queue with repeat all enabled +- Fixed artist choice dialog not showing up on home screen if playing from artist/genre was enabled + ## 3.4.2 #### What's Fixed diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 41a1055a7..5744a389e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -75,6 +75,7 @@ import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.external.M3U +import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -214,12 +215,13 @@ class HomeFragment : collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) collect(homeModel.speedDialOpen, ::updateSpeedDial) + collect(detailModel.toShow.flow, ::handleShow) collect(listModel.menu.flow, ::handleMenu) collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) collectImmediately(musicModel.playlistMessage.flow, ::handlePlaylistMessage) - collect(detailModel.toShow.flow, ::handleShow) + collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } override fun onResume() { @@ -487,7 +489,7 @@ class HomeFragment : } } - private fun handleDecision(decision: PlaylistDecision?) { + private fun handlePlaylistDecision(decision: PlaylistDecision?) { if (decision == null) return val directions = when (decision) { @@ -539,6 +541,20 @@ class HomeFragment : musicModel.playlistMessage.consume() } + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + when (decision) { + is PlaybackDecision.PlayFromArtist -> { + findNavController() + .navigateSafe(HomeFragmentDirections.playFromArtist(decision.song.uid)) + } + is PlaybackDecision.PlayFromGenre -> { + findNavController() + .navigateSafe(HomeFragmentDirections.playFromGenre(decision.song.uid)) + } + null -> {} + } + } + private fun updateFab(songs: List, isFastScrolling: Boolean) { updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt index 8e318ab3c..67f295466 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt @@ -70,7 +70,7 @@ class PlayFromArtistDialog : } playbackModel.playbackDecision.consume() - pickerModel.setPickerSongUid(args.artistUid) + pickerModel.setPickerSongUid(args.songUid) collectImmediately(pickerModel.currentPickerSong, ::updateSong) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt index 831e5c6e0..dfc522d3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt @@ -70,7 +70,7 @@ class PlayFromGenreDialog : } playbackModel.playbackDecision.consume() - pickerModel.setPickerSongUid(args.genreUid) + pickerModel.setPickerSongUid(args.songUid) collectImmediately(pickerModel.currentPickerSong, ::updateSong) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index d3d41f61e..833ef1290 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -368,10 +368,7 @@ constructor( C.INDEX_UNSET } else { currTimeline.getNextWindowIndex( - player.currentMediaItemIndex, - Player.REPEAT_MODE_OFF, - player.shuffleModeEnabled - ) + player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled) } if (nextIndex == C.INDEX_UNSET) { diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index c688464cb..ff6f18d10 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -491,7 +491,7 @@ android:label="play_from_artist_dialog" tools:layout="@layout/dialog_music_choices"> @@ -501,7 +501,7 @@ android:label="play_from_genre_dialog" tools:layout="@layout/dialog_music_choices"> From da07be26f45e83bb09e5b0310b37607f1b578b68 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 4 Apr 2024 12:31:02 -0600 Subject: [PATCH 021/110] playback: re-add replaygain off mode It was honestly foolish for me to recommend retagging. Resolves #746. --- app/src/main/java/org/oxycblt/auxio/IntegerTable.kt | 2 +- .../java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt | 3 +++ .../auxio/playback/replaygain/ReplayGainAudioProcessor.kt | 5 +++++ app/src/main/res/values/settings.xml | 3 +++ app/src/main/res/values/strings.xml | 3 ++- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 54d59eb50..271de0969 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -102,7 +102,7 @@ object IntegerTable { /** Sort.Mode.ByDateAdded */ const val SORT_BY_DATE_ADDED = 0xA118 /** ReplayGainMode.Off (No longer used but still reserved) */ - // const val REPLAY_GAIN_MODE_OFF = 0xA110 + const val REPLAY_GAIN_MODE_OFF = 0xA110 /** ReplayGainMode.Track */ const val REPLAY_GAIN_MODE_TRACK = 0xA111 /** ReplayGainMode.Album */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt index 5a19c7df3..012bdfce3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt @@ -29,6 +29,8 @@ import org.oxycblt.auxio.R * @author Alexander Capehart (OxygenCobalt) */ enum class ReplayGainMode { + /** Do not apply any ReplayGain adjustments. */ + OFF, /** Apply the track gain, falling back to the album gain if the track gain is not found. */ TRACK, /** Apply the album gain, falling back to the track gain if the album gain is not found. */ @@ -45,6 +47,7 @@ enum class ReplayGainMode { */ fun fromIntCode(intCode: Int) = when (intCode) { + IntegerTable.REPLAY_GAIN_MODE_OFF -> OFF IntegerTable.REPLAY_GAIN_MODE_TRACK -> TRACK IntegerTable.REPLAY_GAIN_MODE_ALBUM -> ALBUM IntegerTable.REPLAY_GAIN_MODE_DYNAMIC -> DYNAMIC diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index c94f32ae5..09168842d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -119,6 +119,11 @@ constructor( // ReplayGain is configurable, so determine what to do based off of the mode. val resolvedAdjustment = when (playbackSettings.replayGainMode) { + // User wants no adjustment. + ReplayGainMode.OFF -> { + logD("ReplayGain is off") + null + } // User wants track gain to be preferred. Default to album gain only if // there is no track gain. ReplayGainMode.TRACK -> { diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index f5c45132a..49dbceebd 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -139,12 +139,14 @@ + @string/set_replay_gain_mode_off @string/set_replay_gain_mode_track @string/set_replay_gain_mode_album @string/set_replay_gain_mode_dynamic + @integer/replay_gain_off @integer/replay_gain_track @integer/replay_gain_album @integer/replay_gain_dynamic @@ -161,6 +163,7 @@ 0xA122 0xA124 + 0xA110 0xA111 0xA112 0xA113 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a465a49c..e4431dacd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,8 +290,9 @@ Pause when a song repeats Remember pause Remain playing/paused when skipping or editing queue - ReplayGain + Volume normalization ReplayGain strategy + Off Prefer track Prefer album Prefer album if one is playing From 6491dddc2b0f277ebf972586668f719d05ba6e6d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 4 Apr 2024 14:47:04 -0600 Subject: [PATCH 022/110] build: bump to 3.4.3 Bump to version 3.4.3 (44). --- CHANGELOG.md | 3 +++ README.md | 4 ++-- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/44.txt | 3 +++ 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/44.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a877142..da465569a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +#### What's Improved +- Added back option disable ReplayGain for poorly tagged libraries + #### What's Fixed - Fixed crash when using play next on the end of a queue or with a single-song queue - Fixed weird behavior if using play next on the end of a queue with repeat all enabled diff --git a/README.md b/README.md index 03010bdbe..168184c01 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index 868e7d8f3..147514045 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.4.2" - versionCode 43 + versionName "3.4.3" + versionCode 44 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/44.txt b/fastlane/metadata/android/en-US/changelogs/44.txt new file mode 100644 index 000000000..005811807 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/44.txt @@ -0,0 +1,3 @@ +Auxio 3.4.0 adds gapless playback and new widget designs, alongside a variety of fixes. +This release fixes issues identified in the previous version. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.4.3 \ No newline at end of file From d27d99be5381bf5d0a0b1e15caf06a33166dc3a0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 4 Apr 2024 14:47:30 -0600 Subject: [PATCH 023/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index f9a17d51d..e585deaa9 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit f9a17d51d6b711cf29823fe543091e647d9f83fc +Subproject commit e585deaa94cc679ab4fd0a653cc1bf67abb54b7e From 04ea6834fb8c413f3a2101fd081319fa4bd54ad3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 7 Apr 2024 23:25:06 -0600 Subject: [PATCH 024/110] playback: rearchitecture around media3 (prototype) Nowhere near complete in any capacity. --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 9 +- .../java/org/oxycblt/auxio/IntegerTable.kt | 3 + .../oxycblt/auxio/music/MusicRepository.kt | 4 +- .../java/org/oxycblt/auxio/music/info/Date.kt | 6 +- .../music/service/IndexerServiceFragment.kt | 274 --- .../playback/service/MediaButtonReceiver.kt | 3 +- .../playback/service/MediaSessionComponent.kt | 482 ------ .../playback/service/NotificationComponent.kt | 162 -- .../service/PlaybackServiceFragment.kt | 782 --------- .../org/oxycblt/auxio/service/AuxioService.kt | 1476 ++++++++++++++++- .../service/IndexerNotifications.kt | 17 +- .../oxycblt/auxio/service/ServiceFragment.kt | 12 +- .../oxycblt/auxio/widgets/WidgetProvider.kt | 14 +- media | 2 +- 15 files changed, 1500 insertions(+), 1747 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionComponent.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/NotificationComponent.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt rename app/src/main/java/org/oxycblt/auxio/{music => }/service/IndexerNotifications.kt (90%) diff --git a/app/build.gradle b/app/build.gradle index 147514045..4e2969861 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,6 +126,7 @@ dependencies { // --- THIRD PARTY --- // Exoplayer (Vendored) + implementation project(":media-lib-session") implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c657ec84b..e807194f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,8 +85,13 @@ android:name=".service.AuxioService" android:foregroundServiceType="mediaPlayback" android:icon="@mipmap/ic_launcher" - android:exported="false" - android:roundIcon="@mipmap/ic_launcher" /> + android:exported="true" + android:roundIcon="@mipmap/ic_launcher"> + + + + + . + */ + +package org.oxycblt.auxio + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.database.ContentObserver +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.provider.MediaStore +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import coil.ImageLoader +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.guava.asListenableFuture +import org.oxycblt.auxio.image.service.NeoBitmapLoader +import org.oxycblt.auxio.music.IndexingProgress +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.fs.contentResolverSafe +import org.oxycblt.auxio.music.service.IndexingNotification +import org.oxycblt.auxio.music.service.MusicMediaItemBrowser +import org.oxycblt.auxio.music.service.ObservingNotification +import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder +import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +// TODO: Android Auto Hookup +// TODO: Custom notif + +@AndroidEntryPoint +class AuxioService : + MediaLibraryService(), + MediaLibrarySession.Callback, + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener, + PlaybackStateManager.Listener, + PlaybackSettings.Listener { + private val serviceJob = Job() + private var inPlayback = false + + @Inject lateinit var musicRepository: MusicRepository + @Inject lateinit var musicSettings: MusicSettings + private lateinit var indexingNotification: IndexingNotification + private lateinit var observingNotification: ObservingNotification + private lateinit var wakeLock: PowerManager.WakeLock + private lateinit var indexerContentObserver: SystemContentObserver + private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + + @Inject lateinit var playbackManager: PlaybackStateManager + @Inject lateinit var playbackSettings: PlaybackSettings + @Inject lateinit var systemReceiver: SystemPlaybackReceiver + @Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory + private lateinit var exoHolder: ExoPlaybackStateHolder + + @Inject lateinit var bitmapLoader: NeoBitmapLoader + @Inject lateinit var imageLoader: ImageLoader + + @Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser + private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default) + private lateinit var mediaSession: MediaLibrarySession + + @SuppressLint("WrongConstant") + override fun onCreate() { + super.onCreate() + + indexingNotification = IndexingNotification(this) + observingNotification = ObservingNotification(this) + wakeLock = + getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") + + exoHolder = exoHolderFactory.create() + + mediaSession = + MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this) + .setBitmapLoader(bitmapLoader) + .build() + + // Initialize any listener-dependent components last as we wouldn't want a listener race + // condition to cause us to load music before we were fully initialize. + indexerContentObserver = SystemContentObserver() + + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) + .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") + .setChannelName(R.string.lbl_playback) + .build() + .also { it.setSmallIcon(R.drawable.ic_auxio_24) }) + addSession(mediaSession) + updateCustomButtons() + + // Initialize any listener-dependent components last as we wouldn't want a listener race + // condition to cause us to load music before we were fully initialize. + exoHolder.attach() + playbackManager.addListener(this) + playbackSettings.registerListener(this) + + ContextCompat.registerReceiver( + this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + + musicMediaItemBrowser.attach() + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!playbackManager.progression.isPlaying) { + // Stop the service if not playing, continue playing in the background + // otherwise. + endSession() + } + } + + override fun onDestroy() { + super.onDestroy() + // De-initialize core service components first. + serviceJob.cancel() + wakeLock.releaseSafe() + // Then cancel the listener-dependent components to ensure that stray reloading + // events will not occur. + indexerContentObserver.release() + exoHolder.release() + musicSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) + musicRepository.unregisterWorker(this) + + // Pause just in case this destruction was unexpected. + playbackManager.playing(false) + playbackManager.unregisterStateHolder(exoHolder) + playbackSettings.unregisterListener(this) + + removeSession(mediaSession) + mediaSession.release() + unregisterReceiver(systemReceiver) + exoHolder.release() + } + + // --- INDEXER OVERRIDES --- + + override fun requestIndex(withCache: Boolean) { + logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") + // Cancel the previous music loading job. + currentIndexJob?.cancel() + // Start a new music loading job on a co-routine. + currentIndexJob = musicRepository.index(this, withCache) + } + + override val workerContext: Context + get() = this + + override val scope = indexScope + + override fun onIndexingStateChanged() { + updateForeground(forMusic = true) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + logD("Music changed, updating shared objects") + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } + } + + // --- INTERNAL --- + + private fun updateForeground(forMusic: Boolean) { + if (playbackManager.progression.isPlaying) { + inPlayback = true + } + + if (inPlayback) { + if (!forMusic) { + val notification = + mediaNotificationProvider.createNotification( + mediaSession, + mediaSession.customLayout, + mediaNotificationManager.actionFactory) { notification -> + postMediaNotification(notification, mediaSession) + } + postMediaNotification(notification, mediaSession) + } + return + } + + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + updateLoadingForeground(state.progress) + } else { + updateIdleForeground() + } + } + + private fun updateLoadingForeground(progress: IndexingProgress) { + // When loading, we want to enter the foreground state so that android does + // not shut off the loading process. Note that while we will always post the + // notification when initially starting, we will not update the notification + // unless it indicates that it has changed. + val changed = indexingNotification.updateIndexingState(progress) + if (changed) { + logD("Notification changed, re-posting notification") + startForeground(indexingNotification.code, indexingNotification.build()) + } + // Make sure we can keep the CPU on while loading music + wakeLock.acquireSafe() + } + + private fun updateIdleForeground() { + if (musicSettings.shouldBeObserving) { + // There are a few reasons why we stay in the foreground with automatic rescanning: + // 1. Newer versions of Android have become more and more restrictive regarding + // how a foreground service starts. Thus, it's best to go foreground now so that + // we can go foreground later. + // 2. If a non-foreground service is killed, the app will probably still be alive, + // and thus the music library will not be updated at all. + // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need + // this anymore, or at least I only have to use it when the app task is not removed. + logD("Need to observe, staying in foreground") + startForeground(observingNotification.code, observingNotification.build()) + } else { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + } + // Release our wake lock (if we were using it) + wakeLock.releaseSafe() + } + + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.acquireSafe() { + // Avoid unnecessary acquire calls. + if (!wakeLock.isHeld) { + logD("Acquiring wake lock") + // Time out after a minute, which is the average music loading time for a medium-sized + // library. If this runs out, we will re-request the lock, and if music loading is + // shorter than the timeout, it will be released early. + acquire(WAKELOCK_TIMEOUT_MS) + } + } + + /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.releaseSafe() { + // Avoid unnecessary release calls. + if (wakeLock.isHeld) { + logD("Releasing wake lock") + release() + } + } + + // --- SETTING CALLBACKS --- + + override fun onIndexingSettingChanged() { + // Music loading configuration changed, need to reload music. + requestIndex(true) + } + + override fun onObservingChanged() { + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (currentIndexJob == null) { + logD("Not loading, updating idle session") + updateForeground(forMusic = false) + } + } + + /** + * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior + * known to the user as automatic rescanning. The active (and not passive) nature of observing + * the database is what requires [IndexerService] to stay foreground when this is enabled. + */ + private inner class SystemContentObserver : + ContentObserver(Handler(Looper.getMainLooper())), Runnable { + private val handler = Handler(Looper.getMainLooper()) + + init { + contentResolverSafe.registerContentObserver( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) + } + + /** + * Release this instance, preventing it from further observing the database and cancelling + * any pending update events. + */ + fun release() { + handler.removeCallbacks(this) + contentResolverSafe.unregisterContentObserver(this) + } + + override fun onChange(selfChange: Boolean) { + // Batch rapid-fire updates to the library into a single call to run after 500ms + handler.removeCallbacks(this) + handler.postDelayed(this, REINDEX_DELAY_MS) + } + + override fun run() { + // Check here if we should even start a reindex. This is much less bug-prone than + // registering and de-registering this component as this setting changes. + if (musicSettings.shouldBeObserving) { + logD("MediaStore changed, starting re-index") + requestIndex(true) + } + } + } + + // --- SERVICE MANAGEMENT --- + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = + mediaSession + + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + logD("Notification update requested") + updateForeground(forMusic = false) + } + + private fun postMediaNotification(notification: MediaNotification, session: MediaSession) { + // Pulled from MediaNotificationManager: Need to specify MediaSession token manually + // in notification + val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token + notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) + startForeground(notification.notificationId, notification.notification) + } + + // --- MEDIASESSION CALLBACKS --- + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + val sessionCommands = + ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + .add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) + .add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) + .add(SessionCommand(ACTION_EXIT, Bundle.EMPTY)) + .build() + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = + when (customCommand.customAction) { + ACTION_INC_REPEAT_MODE -> { + logD(playbackManager.repeatMode.increment()) + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + ACTION_INVERT_SHUFFLE -> { + playbackManager.shuffled(!playbackManager.isShuffled) + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + ACTION_EXIT -> { + endSession() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + else -> super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> = + Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params)) + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val result = + musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(result) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val children = + musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(children) + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: LibraryParams? + ): ListenableFuture> = + waitScope + .async { + musicMediaItemBrowser.prepareSearch(query) + LibraryResult.ofVoid() + } + .asListenableFuture() + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ) = + waitScope + .async { + musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + .asListenableFuture() + + // --- BUTTON MANAGEMENT --- + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + updateCustomButtons() + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + super.onQueueReordered(queue, index, isShuffled) + updateCustomButtons() + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + super.onRepeatModeChanged(repeatMode) + updateCustomButtons() + } + + override fun onNotificationActionChanged() { + super.onNotificationActionChanged() + updateCustomButtons() + } + + private fun updateCustomButtons() { + val actions = mutableListOf() + + when (playbackSettings.notificationAction) { + ActionMode.REPEAT -> { + actions.add( + CommandButton.Builder() + .setIconResId(playbackManager.repeatMode.icon) + .setDisplayName(getString(R.string.desc_change_repeat)) + .setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle())) + .build()) + } + ActionMode.SHUFFLE -> { + actions.add( + CommandButton.Builder() + .setIconResId( + if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 + else R.drawable.ic_shuffle_off_24) + .setDisplayName(getString(R.string.lbl_shuffle)) + .setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle())) + .build()) + } + else -> {} + } + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_close_24) + .setDisplayName(getString(R.string.desc_exit)) + .setSessionCommand(SessionCommand(ACTION_EXIT, Bundle())) + .build()) + + mediaSession.setCustomLayout(actions) + } + + private fun endSession() { + // This session has ended, so we need to reset this flag for when the next + // session starts. + exoHolder.save { + // User could feasibly start playing again if they were fast enough, so + // we need to avoid stopping the foreground state if that's the case. + if (playbackManager.progression.isPlaying) { + playbackManager.playing(false) + } + inPlayback = false + updateForeground(forMusic = false) + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + const val REINDEX_DELAY_MS = 500L + const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" + const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" + const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" + const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" + const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" + const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index f997f3b0c..e727316fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -31,7 +31,6 @@ import javax.inject.Inject import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt new file mode 100644 index 000000000..050166483 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 Auxio Project + * CoilBitmapLoader.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image.service + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.BitmapLoader +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import javax.inject.Inject +import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.service.MediaSessionUID + +class NeoBitmapLoader +@Inject +constructor( + private val musicRepository: MusicRepository, + private val bitmapProvider: BitmapProvider +) : BitmapLoader { + override fun decodeBitmap(data: ByteArray): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmap(uri: Uri): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null + val future = SettableFuture.create() + val song = + when (val uid = + metadata.extras?.getString("uid")?.let { MediaSessionUID.fromString(it) }) { + is MediaSessionUID.Single -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.Joined -> deviceLibrary.findSong(uid.childUid) + else -> return null + } + ?: return null + bitmapProvider.load( + song, + object : BitmapProvider.Target { + override fun onCompleted(bitmap: Bitmap?) { + if (bitmap == null) { + future.setException(IllegalStateException("Bitmap is null")) + } else { + future.set(bitmap) + } + } + }) + return future + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index 402c3703e..2b1524fdf 100644 --- a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.service +package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock @@ -25,6 +25,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress +import org.oxycblt.auxio.ui.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt new file mode 100644 index 000000000..fcf65715a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaItemTranslation.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.getPlural + +fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { + val metadata = + MediaMetadata.Builder() + .setTitle(context.getString(nameRes)) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build() + return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() +} + +fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setArtist(artists.resolveNames(context)) + .setAlbumTitle(album.name.resolve(context)) + .setAlbumArtist(album.artists.resolveNames(context)) + .setTrackNumber(track) + .setDiscNumber(disc?.number) + .setGenre(genres.resolveNames(context)) + .setDisplayTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setRecordingYear(album.dates?.min?.year) + .setRecordingMonth(album.dates?.min?.month) + .setRecordingDay(album.dates?.min?.day) + .setReleaseYear(album.dates?.min?.year) + .setReleaseMonth(album.dates?.min?.month) + .setReleaseDay(album.dates?.min?.day) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .setIsPlayable(true) + .setIsBrowsable(false) + .setArtworkUri(album.coverUri.mediaStore) + .setExtras( + Bundle().apply { + putString("uid", mediaSessionUID.toString()) + putLong("durationMs", durationMs) + }) + .build() + return MediaItem.Builder() + .setUri(uri) + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setArtist(artists.resolveNames(context)) + .setAlbumTitle(name.resolve(context)) + .setAlbumArtist(artists.resolveNames(context)) + .setRecordingYear(dates?.min?.year) + .setRecordingMonth(dates?.min?.month) + .setRecordingDay(dates?.min?.day) + .setReleaseYear(dates?.min?.year) + .setReleaseMonth(dates?.min?.month) + .setReleaseDay(dates?.min?.day) + .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(coverUri.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + })) + .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) + .setIsPlayable(true) + .setIsBrowsable(true) + .setGenre(genres.resolveNames(context)) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Genre.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Playlist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { + val uid = MediaSessionUID.fromString(mediaId) ?: return null + return when (uid) { + is MediaSessionUID.Single -> { + deviceLibrary.findSong(uid.uid) + } + is MediaSessionUID.Joined -> { + deviceLibrary.findSong(uid.childUid) + } + is MediaSessionUID.Category -> null + } +} + +sealed interface MediaSessionUID { + enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : + MediaSessionUID { + ROOT("root", R.string.info_app_name, null), + SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), + ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), + PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); + + override fun toString() = "$ID_CATEGORY:$id" + } + + data class Single(val uid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$uid" + } + + data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$parentUid>$childUid" + } + + companion object { + const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" + const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" + + fun fromString(str: String): MediaSessionUID? { + val parts = str.split(":", limit = 2) + if (parts.size != 2) { + return null + } + return when (parts[0]) { + ID_CATEGORY -> + when (parts[1]) { + Category.ROOT.id -> Category.ROOT + Category.SONGS.id -> Category.SONGS + Category.ALBUMS.id -> Category.ALBUMS + Category.ARTISTS.id -> Category.ARTISTS + Category.GENRES.id -> Category.GENRES + Category.PLAYLISTS.id -> Category.PLAYLISTS + else -> null + } + ID_ITEM -> { + val uids = parts[1].split(">", limit = 2) + if (uids.size == 1) { + Music.UID.fromString(uids[0])?.let { Single(it) } + } else { + Music.UID.fromString(uids[0])?.let { parent -> + Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } + } + } + } + else -> return null + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt new file mode 100644 index 000000000..00876951f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicMediaItemBrowser.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import androidx.media3.common.MediaItem +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine + +class MusicMediaItemBrowser +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine +) : MusicRepository.UpdateListener { + private val browserJob = Job() + private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchResults = mutableMapOf>() + + fun attach() { + musicRepository.addUpdateListener(this) + } + + fun release() { + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary) { + for (entry in searchResults.entries) { + entry.value.cancel() + } + searchResults.clear() + } + } + + val root: MediaItem + get() = MediaSessionUID.Category.ROOT.toMediaItem(context) + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.Single -> + musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + is MediaSessionUID.Joined -> + musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + null -> null + } + ?: return null + + return when (music) { + is Album -> music.toMediaItem(context, null) + is Artist -> music.toMediaItem(context, null) + is Genre -> music.toMediaItem(context) + is Playlist -> music.toMediaItem(context) + is Song -> music.toMediaItem(context, null) + } + } + + fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null + return items.paginate(page, pageSize) + } + + private fun getMediaItemList( + id: String, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Category -> { + when (mediaSessionUID) { + MediaSessionUID.Category.ROOT -> + listOf( + MediaSessionUID.Category.SONGS, + MediaSessionUID.Category.ALBUMS, + MediaSessionUID.Category.ARTISTS, + MediaSessionUID.Category.GENRES, + MediaSessionUID.Category.PLAYLISTS) + .map { it.toMediaItem(context) } + MediaSessionUID.Category.SONGS -> + deviceLibrary.songs.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.ALBUMS -> + deviceLibrary.albums.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.ARTISTS -> + deviceLibrary.artists.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.GENRES -> + deviceLibrary.genres.map { it.toMediaItem(context) } + MediaSessionUID.Category.PLAYLISTS -> + userLibrary.playlists.map { it.toMediaItem(context) } + } + } + is MediaSessionUID.Single -> { + getChildMediaItems(mediaSessionUID.uid) ?: return null + } + is MediaSessionUID.Joined -> { + getChildMediaItems(mediaSessionUID.childUid) ?: return null + } + null -> return null + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + return when (val item = musicRepository.find(uid)) { + is Album -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Artist -> { + (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context, item) } + + item.songs.map { it.toMediaItem(context, item) } + } + is Genre -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Playlist -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Song, + null -> return null + } + } + + suspend fun prepareSearch(query: String) { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return + } + + if (query.isEmpty()) { + return + } + + searchTo(query, deviceLibrary, userLibrary).await() + } + + suspend fun getSearchResult( + query: String, + page: Int, + pageSize: Int, + ): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + if (query.isEmpty()) { + return listOf() + } + + val existing = searchResults[query] + if (existing != null) { + return existing.await().concat().paginate(page, pageSize) + } + + return searchTo(query, deviceLibrary, userLibrary).await().concat().paginate(page, pageSize) + } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context, null) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context, null) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } + + private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + searchScope.async { + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) + searchEngine.search(items, query) + } + + private fun List.paginate(page: Int, pageSize: Int): List? { + if (page == Int.MAX_VALUE) { + // I think if someone requests this page it more or less implies that I should + // return all of the pages. + return this + } + val start = page * pageSize + val end = (page + 1) * pageSize + if (pageSize == 0 || start !in indices || end - 1 !in indices) { + // These pages are probably invalid. Hopefully this won't backfire. + return null + } + return subList(page * pageSize, (page + 1) * pageSize).toMutableList() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt new file mode 100644 index 000000000..3ae7b7edb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -0,0 +1,551 @@ +/* + * Copyright (c) 2024 Auxio Project + * ExoPlaybackStateHolder.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.Context +import android.content.Intent +import android.media.audiofx.AudioEffect +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.MediaSource +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.toMediaItem +import org.oxycblt.auxio.music.service.toSong +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.persist.PersistenceRepository +import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateHolder +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.RawQueue +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.playback.state.StateAck +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE + +class ExoPlaybackStateHolder( + private val context: Context, + private val player: ExoPlayer, + private val playbackManager: PlaybackStateManager, + private val persistenceRepository: PersistenceRepository, + private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository +) : + PlaybackStateHolder, + Player.Listener, + MusicRepository.UpdateListener, + PlaybackSettings.Listener { + private val saveJob = Job() + private val saveScope = CoroutineScope(Dispatchers.IO + saveJob) + private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob) + private var currentSaveJob: Job? = null + private var openAudioEffectSession = false + + fun attach() { + player.addListener(this) + playbackManager.registerStateHolder(this) + playbackSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + fun release() { + saveJob.cancel() + player.removeListener(this) + playbackManager.unregisterStateHolder(this) + musicRepository.removeUpdateListener(this) + player.release() + } + + override var parent: MusicParent? = null + private set + + val mediaSessionPlayer: Player + get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository) + + override val progression: Progression + get() { + val mediaItem = player.currentMediaItem ?: return Progression.nil() + val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE + val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration) + return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition) + } + + override val repeatMode + get() = + when (val repeatMode = player.repeatMode) { + Player.REPEAT_MODE_OFF -> RepeatMode.NONE + Player.REPEAT_MODE_ONE -> RepeatMode.TRACK + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") + } + + override val audioSessionId: Int + get() = player.audioSessionId + + override fun resolveQueue(): RawQueue { + val deviceLibrary = + musicRepository.deviceLibrary + // No library, cannot do anything. + ?: return RawQueue(emptyList(), emptyList(), 0) + val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } + val shuffledMapping = + if (player.shuffleModeEnabled) { + player.unscrambleQueueIndices() + } else { + emptyList() + } + return RawQueue( + heap.mapNotNull { it.toSong(deviceLibrary) }, + shuffledMapping, + player.currentMediaItemIndex) + } + + override fun handleDeferred(action: DeferredPlayback): Boolean { + val deviceLibrary = + musicRepository.deviceLibrary + // No library, cannot do anything. + ?: return false + + when (action) { + // Restore state -> Start a new restoreState job + is DeferredPlayback.RestoreState -> { + logD("Restoring playback state") + restoreScope.launch { + persistenceRepository.readState()?.let { + // Apply the saved state on the main thread to prevent code expecting + // state updates on the main thread from crashing. + withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } + } + } + } + // Shuffle all -> Start new playback from all songs + is DeferredPlayback.ShuffleAll -> { + logD("Shuffling all tracks") + playbackManager.play( + requireNotNull(commandFactory.all(ShuffleMode.ON)) { + "Invalid playback parameters" + }) + } + // Open -> Try to find the Song for the given file and then play it from all songs + is DeferredPlayback.Open -> { + logD("Opening specified file") + deviceLibrary.findSongForUri(context, action.uri)?.let { song -> + playbackManager.play( + requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) { + "Invalid playback parameters" + }) + } + } + } + + return true + } + + override fun playing(playing: Boolean) { + player.playWhenReady = playing + } + + override fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + // Ack/state save handled on discontinuity + } + + override fun repeatMode(repeatMode: RepeatMode) { + player.repeatMode = + when (repeatMode) { + RepeatMode.NONE -> Player.REPEAT_MODE_OFF + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.TRACK -> Player.REPEAT_MODE_ONE + } + updatePauseOnRepeat() + playbackManager.ack(this, StateAck.RepeatModeChanged) + deferSave() + } + + override fun newPlayback(command: PlaybackCommand) { + parent = command.parent + player.shuffleModeEnabled = command.shuffled + player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + val startIndex = + command.song + ?.let { command.queue.indexOf(it) } + .also { check(it != -1) { "Start song not in queue" } } + if (command.shuffled) { + player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1)) + } + val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled) + player.seekTo(target, C.TIME_UNSET) + player.prepare() + player.play() + playbackManager.ack(this, StateAck.NewPlayback) + deferSave() + } + + override fun shuffled(shuffled: Boolean) { + player.setShuffleModeEnabled(shuffled) + if (player.shuffleModeEnabled) { + // Have to manually refresh the shuffle seed and anchor it to the new current songs + player.setShuffleOrder( + BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) + } + playbackManager.ack(this, StateAck.QueueReordered) + deferSave() + } + + override fun next() { + // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. + // Basically, you can't skip back and wrap around the queue, but you can skip forward and + // wrap around the queue, albeit playback will be paused. + if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) { + player.seekToNext() + if (!playbackSettings.rememberPause) { + player.play() + } + } else { + player.seekTo( + player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET) + // TODO: Dislike the UX implications of this, I feel should I bite the bullet + // and switch to dynamic skip enable/disable? + if (!playbackSettings.rememberPause) { + player.pause() + } + } + // Ack/state save is handled in timeline change + } + + override fun prev() { + if (playbackSettings.rewindWithPrev) { + player.seekToPrevious() + } else { + player.seekToPreviousMediaItem() + } + if (!playbackSettings.rememberPause) { + player.play() + } + // Ack/state save is handled in timeline change + } + + override fun goto(index: Int) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[index] + player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic + if (!playbackSettings.rememberPause) { + player.play() + } + // Ack/state save is handled in timeline change + } + + override fun playNext(songs: List, ack: StateAck.PlayNext) { + val currTimeline = player.currentTimeline + val nextIndex = + if (currTimeline.isEmpty) { + C.INDEX_UNSET + } else { + currTimeline.getNextWindowIndex( + player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled) + } + + if (nextIndex == C.INDEX_UNSET) { + player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + } else { + player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { + player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + playbackManager.ack(this, ack) + deferSave() + } + + override fun move(from: Int, to: Int, ack: StateAck.Move) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueFrom = indices[from] + val trueTo = indices[to] + when { + trueFrom > trueTo -> { + player.moveMediaItem(trueFrom, trueTo) + } + trueTo > trueFrom -> { + player.moveMediaItem(trueFrom, trueTo) + } + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun remove(at: Int, ack: StateAck.Remove) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[at] + val songWillChange = player.currentMediaItemIndex == trueIndex + player.removeMediaItem(trueIndex) + if (songWillChange && !playbackSettings.rememberPause) { + player.play() + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun applySavedState( + parent: MusicParent?, + rawQueue: RawQueue, + ack: StateAck.NewPlayback? + ) { + this.parent = parent + player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) + player.prepare() + ack?.let { playbackManager.ack(this, it) } + } + + override fun reset(ack: StateAck.NewPlayback) { + player.setMediaItems(listOf()) + playbackManager.ack(this, ack) + deferSave() + } + + // --- PLAYER OVERRIDES --- + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + + if (player.playWhenReady) { + // Mark that we have started playing so that the notification can now be posted. + logD("Player has started playing") + if (!openAudioEffectSession) { + // Convention to start an audioeffect session on play/pause rather than + // start/stop + logD("Opening audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = true + } + } else if (openAudioEffectSession) { + // Make sure to close the audio session when we stop playback. + logD("Closing audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = false + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || + reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + playbackManager.ack(this, StateAck.IndexMoved) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + + if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { + goto(0) + player.pause() + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // TODO: Once position also naturally drifts by some threshold, save + deferSave() + } + } + + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + + if (events.containsAny( + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY)) { + logD("Player state changed, must synchronize state") + playbackManager.ack(this, StateAck.ProgressionChanged) + } + } + + override fun onPlayerError(error: PlaybackException) { + // TODO: Replace with no skipping and a notification instead + // If there's any issue, just go to the next song. + logE("Player error occurred") + logE(error.stackTraceToString()) + playbackManager.next() + } + + private fun broadcastAudioEffectAction(event: String) { + logD("Broadcasting AudioEffect event: $event") + context.sendBroadcast( + Intent(event) + .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) + } + + // --- MUSICREPOSITORY METHODS --- + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { + // We now have a library, see if we have anything we need to do. + logD("Library obtained, requesting action") + playbackManager.requestAction(this) + } + } + + // --- PLAYBACKSETTINGS OVERRIDES --- + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + updatePauseOnRepeat() + } + + private fun updatePauseOnRepeat() { + player.pauseAtEndOfMediaItems = + player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat + } + + fun save(cb: () -> Unit) { + saveJob { + persistenceRepository.saveState(playbackManager.toSavedState()) + withContext(Dispatchers.Main) { cb() } + } + } + + private fun deferSave() { + saveJob { + logD("Waiting for save buffer") + delay(SAVE_BUFFER) + yield() + logD("Committing saved state") + persistenceRepository.saveState(playbackManager.toSavedState()) + } + } + + private fun saveJob(block: suspend () -> Unit) { + currentSaveJob?.let { + logD("Discarding prior save job") + it.cancel() + } + currentSaveJob = saveScope.launch { block() } + } + + class Factory + @Inject + constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val persistenceRepository: PersistenceRepository, + private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, + private val mediaSourceFactory: MediaSource.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor + ) { + fun create(): ExoPlaybackStateHolder { + // Since Auxio is a music player, only specify an audio renderer to save + // battery/apk size/cache size + val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> + arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), + MediaCodecAudioRenderer( + context, + MediaCodecSelector.DEFAULT, + handler, + audioListener, + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + replayGainProcessor)) + } + + val exoPlayer = + ExoPlayer.Builder(context, audioRenderer) + .setMediaSourceFactory(mediaSourceFactory) + // Enable automatic WakeLock support + .setWakeMode(C.WAKE_MODE_LOCAL) + .setAudioAttributes( + // Signal that we are a music player. + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true) + .build() + + return ExoPlaybackStateHolder( + context, + exoPlayer, + playbackManager, + persistenceRepository, + playbackSettings, + commandFactory, + musicRepository) + } + } + + private companion object { + const val SAVE_BUFFER = 5000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt index e2fe690ec..9ea0300b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt @@ -25,8 +25,8 @@ import android.content.Intent import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.util.logD /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt new file mode 100644 index 000000000..1ea54f92a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionPlayer.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.toSong +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD + +/** + * A thin wrapper around the player instance that takes all the events I know MediaSession will send + * and routes them to PlaybackStateManager so I know that they will work the way I want it to. + * @author Alexander Capehart + */ +class MediaSessionPlayer( + player: Player, + private val playbackManager: PlaybackStateManager, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository +) : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands { + return super.getAvailableCommands() + .buildUpon() + .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) + .build() + } + + override fun isCommandAvailable(command: Int): Boolean { + // We can always skip forward and backward (this is to retain parity with the old behavior) + return super.isCommandAvailable(command) || + command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) + } + + override fun setMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ) { + // We assume the only people calling this method are going to be the MediaSession callbacks, + // since anything else (like newPlayback) will be calling directly on the player. As part + // of this, we expand the given MediaItems into the command that should be sent to the + // player. + val command = + if (mediaItems.size > 1) { + this.playMediaItemSelection(mediaItems, startIndex) + } else { + this.playSingleMediaItem(mediaItems.first()) + } + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) + if (startPositionMs != C.TIME_UNSET) { + playbackManager.seekTo(startPositionMs) + } + } + + private fun playMediaItemSelection( + mediaItems: List, + startIndex: Int + ): PlaybackCommand? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null + val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary) + val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } + var index = startIndex + if (targetSong != null) { + while (songs.getOrNull(index)?.uid != targetSong.uid) { + index-- + } + } + return commandFactory.songs(songs, ShuffleMode.OFF) + } + + private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? { + val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.Single -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.Joined -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParentCommand(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + playbackManager.playing(playWhenReady) + } + + override fun setRepeatMode(repeatMode: Int) { + val appRepeatMode = + when (repeatMode) { + Player.REPEAT_MODE_OFF -> RepeatMode.NONE + Player.REPEAT_MODE_ONE -> RepeatMode.TRACK + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") + } + playbackManager.repeatMode(appRepeatMode) + } + + override fun seekToNext() = playbackManager.next() + + override fun seekToNextMediaItem() = playbackManager.next() + + override fun seekToPrevious() = playbackManager.prev() + + override fun seekToPreviousMediaItem() = playbackManager.prev() + + override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) + + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + val indices = unscrambleQueueIndices() + val fakeIndex = indices.indexOf(mediaItemIndex) + if (fakeIndex < 0) { + return + } + playbackManager.goto(fakeIndex) + if (positionMs == C.TIME_UNSET) { + return + } + playbackManager.seekTo(positionMs) + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } + when { + index == + currentTimeline.getNextWindowIndex( + currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { + playbackManager.playNext(songs) + } + index >= mediaItemCount -> playbackManager.addToQueue(songs) + else -> error("Unsupported index $index") + } + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + playbackManager.shuffled(shuffleModeEnabled) + } + + override fun moveMediaItem(currentIndex: Int, newIndex: Int) { + val indices = unscrambleQueueIndices() + val fakeFrom = indices.indexOf(currentIndex) + if (fakeFrom < 0) { + return + } + val fakeTo = + if (newIndex >= mediaItemCount) { + currentTimeline.getLastWindowIndex(shuffleModeEnabled) + } else { + indices.indexOf(newIndex) + } + if (fakeTo < 0) { + return + } + playbackManager.moveQueueItem(fakeFrom, fakeTo) + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = + error("Multi-item queue moves are unsupported") + + override fun removeMediaItem(index: Int) { + val indices = unscrambleQueueIndices() + val fakeAt = indices.indexOf(index) + if (fakeAt < 0) { + return + } + playbackManager.removeQueueItem(fakeAt) + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) = + error("Any multi-item queue removal is unsupported") + + // These methods I don't want MediaSession calling in any way since they'll do insane things + // that I'm not tracking. If they do call them, I will know. + + override fun setMediaItem(mediaItem: MediaItem) = notAllowed() + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() + + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() + + override fun setMediaItems(mediaItems: MutableList) = notAllowed() + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) = + notAllowed() + + override fun addMediaItem(mediaItem: MediaItem) = notAllowed() + + override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() + + override fun addMediaItems(mediaItems: MutableList) = notAllowed() + + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() + + override fun replaceMediaItems( + fromIndex: Int, + toIndex: Int, + mediaItems: MutableList + ) = notAllowed() + + override fun clearMediaItems() = notAllowed() + + override fun setPlaybackSpeed(speed: Float) = notAllowed() + + override fun seekToDefaultPosition() = notAllowed() + + override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed() + + override fun seekForward() = notAllowed() + + override fun seekBack() = notAllowed() + + @Deprecated("Deprecated in Java") override fun next() = notAllowed() + + @Deprecated("Deprecated in Java") override fun previous() = notAllowed() + + @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() + + @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() + + override fun play() = playbackManager.playing(true) + + override fun pause() = playbackManager.playing(false) + + override fun prepare() = notAllowed() + + override fun release() = notAllowed() + + override fun stop() = notAllowed() + + override fun hasNextMediaItem() = notAllowed() + + override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = + notAllowed() + + override fun setVolume(volume: Float) = notAllowed() + + override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() + + override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() + + override fun increaseDeviceVolume(flags: Int) = notAllowed() + + override fun decreaseDeviceVolume(flags: Int) = notAllowed() + + @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() + + @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() + + @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() + + @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() + + override fun setVideoSurface(surface: Surface?) = notAllowed() + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() + + override fun setVideoTextureView(textureView: TextureView?) = notAllowed() + + override fun clearVideoSurface() = notAllowed() + + override fun clearVideoSurface(surface: Surface?) = notAllowed() + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() + + override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() + + private fun notAllowed(): Nothing = error("MediaSession unexpectedly called this method") +} + +fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt new file mode 100644 index 000000000..2d73d5897 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemPlaybackReciever.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import javax.inject.Inject +import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent +import org.oxycblt.auxio.widgets.WidgetProvider + +/** + * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an + * active [IntentFilter] to be registered. + */ +class SystemPlaybackReceiver +@Inject +constructor( + val playbackManager: PlaybackStateManager, + val playbackSettings: PlaybackSettings, + val widgetComponent: WidgetComponent +) : BroadcastReceiver() { + private var initialHeadsetPlugEventHandled = false + + val intentFilter = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(AuxioService.ACTION_INC_REPEAT_MODE) + addAction(AuxioService.ACTION_INVERT_SHUFFLE) + addAction(AuxioService.ACTION_SKIP_PREV) + addAction(AuxioService.ACTION_PLAY_PAUSE) + addAction(AuxioService.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // --- SYSTEM EVENTS --- + + // Android has three different ways of handling audio plug events for some reason: + // 1. ACTION_HEADSET_PLUG, which only works with wired headsets + // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires + // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less + // a non-starter since both require me to display a permission prompt + // 3. Some internal framework thing that also handles bluetooth headsets + // Just use ACTION_HEADSET_PLUG. + AudioManager.ACTION_HEADSET_PLUG -> { + logD("Received headset plug event") + when (intent.getIntExtra("state", -1)) { + 0 -> pauseFromHeadsetPlug() + 1 -> playFromHeadsetPlug() + } + + initialHeadsetPlugEventHandled = true + } + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { + logD("Received Headset noise event") + pauseFromHeadsetPlug() + } + + // --- AUXIO EVENTS --- + AuxioService.ACTION_PLAY_PAUSE -> { + logD("Received play event") + playbackManager.playing(!playbackManager.progression.isPlaying) + } + AuxioService.ACTION_INC_REPEAT_MODE -> { + logD("Received repeat mode event") + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + } + AuxioService.ACTION_INVERT_SHUFFLE -> { + logD("Received shuffle event") + playbackManager.shuffled(!playbackManager.isShuffled) + } + AuxioService.ACTION_SKIP_PREV -> { + logD("Received skip previous event") + playbackManager.prev() + } + AuxioService.ACTION_SKIP_NEXT -> { + logD("Received skip next event") + playbackManager.next() + } + WidgetProvider.ACTION_WIDGET_UPDATE -> { + logD("Received widget update event") + widgetComponent.update() + } + } + } + + private fun playFromHeadsetPlug() { + // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, + // which would result in unexpected playback. Work around it by dropping the first + // call to this function, which should come from that Intent. + if (playbackSettings.headsetAutoplay && + playbackManager.currentSong != null && + initialHeadsetPlugEventHandled) { + logD("Device connected, resuming") + playbackManager.playing(true) + } + } + + private fun pauseFromHeadsetPlug() { + if (playbackManager.currentSong != null) { + logD("Device disconnected, pausing") + playbackManager.playing(false) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index cfeef8b04..8780dcdbb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -51,9 +51,7 @@ interface PlaybackStateHolder { /** The current audio session ID of the audio player. */ val audioSessionId: Int - /** - * Applies a completely new playback state to the holder. - */ + /** Applies a completely new playback state to the holder. */ fun newPlayback(command: PlaybackCommand) /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index fc4d5fc7c..c5231e0b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } } - private class QueueCommand(override val queue: List) : PlaybackCommand { + private class QueueCommand(override val queue: List) : PlaybackCommand { override val song: Song? = null override val parent: MusicParent? = null override val shuffled = false diff --git a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt deleted file mode 100644 index 8b1872f77..000000000 --- a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt +++ /dev/null @@ -1,1915 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * AuxioService.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.service - -import android.app.Notification -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.database.ContentObserver -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.AudioManager -import android.media.audiofx.AudioEffect -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.provider.MediaStore -import androidx.annotation.StringRes -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.BitmapLoader -import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.audio.AudioCapabilities -import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaNotification -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.SettableFuture -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.BitmapProvider -import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.IndexingProgress -import org.oxycblt.auxio.music.IndexingState -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.fs.contentResolverSafe -import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.persist.PersistenceRepository -import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor -import org.oxycblt.auxio.playback.service.BetterShuffleOrder -import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateHolder -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Progression -import org.oxycblt.auxio.playback.state.RawQueue -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.playback.state.StateAck -import org.oxycblt.auxio.search.SearchEngine -import org.oxycblt.auxio.util.getPlural -import org.oxycblt.auxio.util.getSystemServiceCompat -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.widgets.WidgetComponent -import org.oxycblt.auxio.widgets.WidgetProvider -import javax.inject.Inject - -// TODO: Android Auto Hookup -// TODO: Have to clobber shuffle and repeat mode handlers - -@AndroidEntryPoint -class AuxioService : - MediaLibraryService(), - MediaLibrarySession.Callback, - MusicRepository.IndexingWorker, - MusicRepository.IndexingListener, - MusicRepository.UpdateListener, - MusicSettings.Listener, - PlaybackStateHolder, - Player.Listener, - PlaybackSettings.Listener { - @Inject - lateinit var musicRepository: MusicRepository - @Inject - lateinit var musicSettings: MusicSettings - - private lateinit var indexingNotification: IndexingNotification - private lateinit var observingNotification: ObservingNotification - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var indexerContentObserver: SystemContentObserver - private val serviceJob = Job() - private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) - private var currentIndexJob: Job? = null - - @Inject - lateinit var playbackManager: PlaybackStateManager - @Inject - lateinit var commandFactory: PlaybackCommand.Factory - @Inject - lateinit var playbackSettings: PlaybackSettings - @Inject - lateinit var persistenceRepository: PersistenceRepository - @Inject - lateinit var mediaSourceFactory: MediaSource.Factory - @Inject - lateinit var replayGainProcessor: ReplayGainAudioProcessor - private lateinit var player: NeoPlayer - private lateinit var mediaSession: MediaLibrarySession - private val systemReceiver = PlaybackReceiver() - private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO) - private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO) - private var currentSaveJob: Job? = null - private var inPlayback = false - private var openAudioEffectSession = false - - @Inject - lateinit var listSettings: ListSettings - @Inject - lateinit var widgetComponent: WidgetComponent - @Inject - lateinit var bitmapLoader: NeoBitmapLoader - - @Inject - lateinit var searchEngine: SearchEngine - private var searchResultsCache = mutableMapOf() - private var searchScope = CoroutineScope(serviceJob + Dispatchers.Default) - private var searchJob: Job? = null - - override fun onCreate() { - super.onCreate() - - indexingNotification = IndexingNotification(this) - observingNotification = ObservingNotification(this) - wakeLock = - getSystemServiceCompat(PowerManager::class) - .newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService" - ) - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - indexerContentObserver = SystemContentObserver() - - // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size - val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> - arrayOf( - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), - MediaCodecAudioRenderer( - this, - MediaCodecSelector.DEFAULT, - handler, - audioListener, - AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor - ) - ) - } - - val exoPlayer = - ExoPlayer.Builder(this, audioRenderer) - .setMediaSourceFactory(mediaSourceFactory) - // Enable automatic WakeLock support - .setWakeMode(C.WAKE_MODE_LOCAL) - .setAudioAttributes( - // Signal that we are a music player. - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true - ) - .build() - .also { it.addListener(this) } - - player = NeoPlayer( - this, - exoPlayer, - musicRepository, - playbackManager, - this, - commandFactory, - playbackSettings - ) - setMediaNotificationProvider( - DefaultMediaNotificationProvider.Builder(this) - .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) - .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") - .setChannelName(R.string.lbl_playback) - .build() - .also { it.setSmallIcon(R.drawable.ic_auxio_24) }) - mediaSession = - MediaLibrarySession.Builder(this, player, this).setBitmapLoader(bitmapLoader).build() - addSession(mediaSession) - updateCustomButtons() - - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(ACTION_INC_REPEAT_MODE) - addAction(ACTION_INVERT_SHUFFLE) - addAction(ACTION_SKIP_PREV) - addAction(ACTION_PLAY_PAUSE) - addAction(ACTION_SKIP_NEXT) - addAction(ACTION_EXIT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } - - ContextCompat.registerReceiver( - this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED - ) - - musicSettings.registerListener(this) - musicRepository.addUpdateListener(this) - musicRepository.addIndexingListener(this) - musicRepository.registerWorker(this) - - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - playbackManager.registerStateHolder(this) - musicRepository.addUpdateListener(this) - playbackSettings.registerListener(this) - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - if (!playbackManager.progression.isPlaying) { - // Stop the service if not playing, continue playing in the background - // otherwise. - endSession() - } - } - - override fun onDestroy() { - super.onDestroy() - // De-initialize core service components first. - wakeLock.releaseSafe() - // Then cancel the listener-dependent components to ensure that stray reloading - // events will not occur. - indexerContentObserver.release() - musicSettings.unregisterListener(this) - musicRepository.removeUpdateListener(this) - musicRepository.removeIndexingListener(this) - musicRepository.unregisterWorker(this) - // Then cancel any remaining music loading jobs. - serviceJob.cancel() - - // Pause just in case this destruction was unexpected. - playbackManager.playing(false) - playbackManager.unregisterStateHolder(this) - musicRepository.removeUpdateListener(this) - playbackSettings.unregisterListener(this) - - serviceJob.cancel() - - replayGainProcessor.release() - player.release() - if (openAudioEffectSession) { - // Make sure to close the audio session when we release the player. - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - - removeSession(mediaSession) - mediaSession.release() - player.release() - } - - // --- INDEXER OVERRIDES --- - - override fun requestIndex(withCache: Boolean) { - logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") - // Cancel the previous music loading job. - currentIndexJob?.cancel() - // Start a new music loading job on a co-routine. - currentIndexJob = musicRepository.index(this, withCache) - } - - override val workerContext: Context - get() = this - - override val scope = indexScope - - override fun onIndexingStateChanged() { - updateForeground(forMusic = true) - } - - // --- INTERNAL --- - - private fun updateForeground(forMusic: Boolean) { - if (inPlayback) { - if (!forMusic) { - val notification = - mediaNotificationProvider.createNotification( - mediaSession, - mediaSession.customLayout, - mediaNotificationManager.actionFactory - ) { notification -> - postMediaNotification(notification, mediaSession) - } - postMediaNotification(notification, mediaSession) - } - return - } - - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - updateLoadingForeground(state.progress) - } else { - updateIdleForeground() - } - } - - private fun updateLoadingForeground(progress: IndexingProgress) { - // When loading, we want to enter the foreground state so that android does - // not shut off the loading process. Note that while we will always post the - // notification when initially starting, we will not update the notification - // unless it indicates that it has changed. - val changed = indexingNotification.updateIndexingState(progress) - if (changed) { - logD("Notification changed, re-posting notification") - startForeground(indexingNotification.code, indexingNotification.build()) - } - // Make sure we can keep the CPU on while loading music - wakeLock.acquireSafe() - } - - private fun updateIdleForeground() { - if (musicSettings.shouldBeObserving) { - // There are a few reasons why we stay in the foreground with automatic rescanning: - // 1. Newer versions of Android have become more and more restrictive regarding - // how a foreground service starts. Thus, it's best to go foreground now so that - // we can go foreground later. - // 2. If a non-foreground service is killed, the app will probably still be alive, - // and thus the music library will not be updated at all. - // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need - // this anymore, or at least I only have to use it when the app task is not removed. - logD("Need to observe, staying in foreground") - startForeground(observingNotification.code, observingNotification.build()) - } else { - // Not observing and done loading, exit foreground. - logD("Exiting foreground") - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) - } - // Release our wake lock (if we were using it) - wakeLock.releaseSafe() - } - - /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.acquireSafe() { - // Avoid unnecessary acquire calls. - if (!wakeLock.isHeld) { - logD("Acquiring wake lock") - // Time out after a minute, which is the average music loading time for a medium-sized - // library. If this runs out, we will re-request the lock, and if music loading is - // shorter than the timeout, it will be released early. - acquire(WAKELOCK_TIMEOUT_MS) - } - } - - /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.releaseSafe() { - // Avoid unnecessary release calls. - if (wakeLock.isHeld) { - logD("Releasing wake lock") - release() - } - } - - // --- SETTING CALLBACKS --- - - override fun onIndexingSettingChanged() { - // Music loading configuration changed, need to reload music. - requestIndex(true) - } - - override fun onObservingChanged() { - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (currentIndexJob == null) { - logD("Not loading, updating idle session") - updateForeground(forMusic = false) - } - } - - /** - * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior - * known to the user as automatic rescanning. The active (and not passive) nature of observing - * the database is what requires [IndexerService] to stay foreground when this is enabled. - */ - private inner class SystemContentObserver : - ContentObserver(Handler(Looper.getMainLooper())), Runnable { - private val handler = Handler(Looper.getMainLooper()) - - init { - contentResolverSafe.registerContentObserver( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this - ) - } - - /** - * Release this instance, preventing it from further observing the database and cancelling - * any pending update events. - */ - fun release() { - handler.removeCallbacks(this) - contentResolverSafe.unregisterContentObserver(this) - } - - override fun onChange(selfChange: Boolean) { - // Batch rapid-fire updates to the library into a single call to run after 500ms - handler.removeCallbacks(this) - handler.postDelayed(this, REINDEX_DELAY_MS) - } - - override fun run() { - // Check here if we should even start a reindex. This is much less bug-prone than - // registering and de-registering this component as this setting changes. - if (musicSettings.shouldBeObserving) { - logD("MediaStore changed, starting re-index") - requestIndex(true) - } - } - } - - // --- PLAYBACKSTATEHOLDER OVERRIDES --- - - override val progression: Progression - get() = - player.currentMediaItem?.let { - Progression.from( - player.playWhenReady, - player.isPlaying, - // The position value can be below zero or past the expected duration, make - // sure we handle that. - player.currentPosition - .coerceAtLeast(0) - .coerceAtMost(player.durationMs ?: Long.MAX_VALUE) - ) - } - ?: Progression.nil() - - override val repeatMode - get() = - when (val repeatMode = player.repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - - override val parent: MusicParent? - get() = player.parent - - override val audioSessionId: Int - get() = player.audioSessionId - - override fun resolveQueue() = player.resolveQueue() - - override fun newPlayback(command: PlaybackCommand) { - player.newPlayback(command) - updateCustomButtons() - deferSave() - } - - override fun playing(playing: Boolean) { - player.playWhenReady = playing - // Dispatched later once all of the changes have been accumulated - // Playing state is not persisted, do not need to save - } - - override fun repeatMode(repeatMode: RepeatMode) { - player.repeatMode(repeatMode) - deferSave() - updateCustomButtons() - } - - override fun seekTo(positionMs: Long) { - player.seekTo(positionMs) - // Dispatched later once all of the changes have been accumulated - // Deferred save is handled on position discontinuity - } - - override fun next() { - player.seekToNext() - // Deferred save is handled on position discontinuity - } - - override fun prev() { - player.seekToPrevious() - // Deferred save is handled on position discontinuity - } - - override fun goto(index: Int) { - player.goto(index) - // Deferred save is handled on position discontinuity - } - - override fun shuffled(shuffled: Boolean) { - logD("Reordering queue to $shuffled") - player.shuffleModeEnabled = shuffled - deferSave() - updateCustomButtons() - } - - override fun playNext(songs: List, ack: StateAck.PlayNext) { - player.playNext(songs, ack) - deferSave() - } - - override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addToQueue(songs, ack) - deferSave() - } - - override fun move(from: Int, to: Int, ack: StateAck.Move) { - player.move(from, to, ack) - deferSave() - } - - override fun remove(at: Int, ack: StateAck.Remove) { - player.remove(at, ack) - deferSave() - } - - override fun handleDeferred(action: DeferredPlayback): Boolean { - val deviceLibrary = - musicRepository.deviceLibrary - // No library, cannot do anything. - ?: return false - - when (action) { - // Restore state -> Start a new restoreState job - is DeferredPlayback.RestoreState -> { - logD("Restoring playback state") - restoreScope.launch { - persistenceRepository.readState()?.let { - // Apply the saved state on the main thread to prevent code expecting - // state updates on the main thread from crashing. - withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } - } - } - } - // Shuffle all -> Start new playback from all songs - is DeferredPlayback.ShuffleAll -> { - logD("Shuffling all tracks") - playbackManager.play( - requireNotNull(commandFactory.all(ShuffleMode.ON)) { - "Invalid playback parameters" - }) - } - // Open -> Try to find the Song for the given file and then play it from all songs - is DeferredPlayback.Open -> { - logD("Opening specified file") - deviceLibrary.findSongForUri(workerContext, action.uri)?.let { song -> - playbackManager.play( - requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) { - "Invalid playback parameters" - }) - } - } - } - - return true - } - - override fun applySavedState( - parent: MusicParent?, - rawQueue: RawQueue, - ack: StateAck.NewPlayback? - ) { - player.applySavedState(parent, rawQueue, ack) - } - - override fun reset(ack: StateAck.NewPlayback) { - player.reset(ack) - } - - // --- PLAYER OVERRIDES --- - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) - - if (player.playWhenReady) { - // Mark that we have started playing so that the notification can now be posted. - logD("Player has started playing") - inPlayback = true - if (!openAudioEffectSession) { - // Convention to start an audioeffect session on play/pause rather than - // start/stop - logD("Opening audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = true - } - } else if (openAudioEffectSession) { - // Make sure to close the audio session when we stop playback. - logD("Closing audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - super.onMediaItemTransition(mediaItem, reason) - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || - reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK - ) { - playbackManager.ack(this, StateAck.IndexMoved) - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - - if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { - goto(0) - player.pause() - } - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - if (reason == Player.DISCONTINUITY_REASON_SEEK) { - // TODO: Once position also naturally drifts by some threshold, save - deferSave() - } - } - - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - - if (events.containsAny( - Player.EVENT_PLAY_WHEN_READY_CHANGED, - Player.EVENT_IS_PLAYING_CHANGED, - Player.EVENT_POSITION_DISCONTINUITY - ) - ) { - logD("Player state changed, must synchronize state") - playbackManager.ack(this, StateAck.ProgressionChanged) - } - } - - override fun onPlayerError(error: PlaybackException) { - // TODO: Replace with no skipping and a notification instead - // If there's any issue, just go to the next song. - logE("Player error occured") - logE(error.stackTraceToString()) - playbackManager.next() - } - - // --- OTHER OVERRIDES --- - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - updateCustomButtons() - } - - override fun onPauseOnRepeatChanged() { - player.updatePauseOnRepeat() - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary) { - if (musicRepository.deviceLibrary != null) { - // We now have a library, see if we have anything we need to do. - logD("Library obtained, requesting action") - playbackManager.requestAction(this) - } - // Invalidate anything we searched prior. - searchResultsCache.clear() - searchJob?.cancel() - } - } - - // --- MEDIASESSION OVERRIDES --- - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSession - - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - updateForeground(forMusic = false) - } - - private fun postMediaNotification(notification: MediaNotification, session: MediaSession) { - // Pulled from MediaNotificationManager: Need to specify MediaSession token manually - // in notification - val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token - notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) - startForeground(notification.notificationId, notification.notification) - } - - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult { - val sessionCommands = - ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() - .add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_EXIT, Bundle.EMPTY)) - .build() - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .build() - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: LibraryParams? - ): ListenableFuture> { - val result = LibraryResult.ofItem(ExternalUID.Category.ROOT.toMediaItem(this), params) - return Futures.immediateFuture(result) - } - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val music = - when (val uid = ExternalUID.fromString(mediaId)) { - is ExternalUID.Category -> - return Futures.immediateFuture( - LibraryResult.ofItem(uid.toMediaItem(this), null) - ) - - is ExternalUID.Single -> - musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - - is ExternalUID.Joined -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - - null -> null - } - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - - val mediaItem = - when (music) { - is Album -> music.toMediaItem(this, null) - is Artist -> music.toMediaItem(this, null) - is Genre -> music.toMediaItem(this) - is Playlist -> music.toMediaItem(this) - is Song -> music.toMediaItem(this, null) - } - - return Futures.immediateFuture(LibraryResult.ofItem(mediaItem, null)) - } - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params)) - } - - val items = - getMediaItemList(parentId, deviceLibrary, userLibrary) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val paginatedItems = - items.paginate(page, pageSize) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val result = LibraryResult.ofItemList(paginatedItems, params) - return Futures.immediateFuture(result) - } - - private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): List? { - return when (val externalUID = ExternalUID.fromString(id)) { - is ExternalUID.Category -> { - when (externalUID) { - ExternalUID.Category.ROOT -> - listOf( - ExternalUID.Category.SONGS, - ExternalUID.Category.ALBUMS, - ExternalUID.Category.ARTISTS, - ExternalUID.Category.GENRES, - ExternalUID.Category.PLAYLISTS - ) - .map { it.toMediaItem(this) } - - ExternalUID.Category.SONGS -> - deviceLibrary.songs.map { it.toMediaItem(this, null) } - - ExternalUID.Category.ALBUMS -> - deviceLibrary.albums.map { it.toMediaItem(this, null) } - - ExternalUID.Category.ARTISTS -> - deviceLibrary.artists.map { it.toMediaItem(this, null) } - - ExternalUID.Category.GENRES -> deviceLibrary.genres.map { it.toMediaItem(this) } - ExternalUID.Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(this) } - } - } - - is ExternalUID.Single -> { - getChildMediaItems(externalUID.uid) ?: return null - } - - is ExternalUID.Joined -> { - getChildMediaItems(externalUID.childUid) ?: return null - } - - null -> return null - } - } - - private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Artist -> { - (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(this, item) } + - item.songs.map { it.toMediaItem(this, item) } - } - - is Genre -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Playlist -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Song, - null -> return null - } - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: LibraryParams? - ): ListenableFuture> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE) - ) - } - - if (query.isEmpty()) { - return Futures.immediateFuture(LibraryResult.ofVoid()) - } - - val future = SettableFuture.create>() - searchTo(query, deviceLibrary, userLibrary) { future.set(LibraryResult.ofVoid()) } - return Futures.immediateFuture(LibraryResult.ofVoid()) - } - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE) - ) - } - - if (query.isEmpty()) { - return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params)) - } - - val items = searchResultsCache[query] - if (items != null) { - val concatenatedItems = items.concat() - val paginatedItems = - concatenatedItems.paginate(page, pageSize) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val result = LibraryResult.ofItemList(paginatedItems, params) - return Futures.immediateFuture(result) - } - - val future = SettableFuture.create>>() - searchTo(query, deviceLibrary, userLibrary) { - val concatenatedItems = it.concat() - val paginatedItems = concatenatedItems.paginate(page, pageSize) ?: return@searchTo - val result = LibraryResult.ofItemList(paginatedItems, params) - future.set(result) - } - - return future - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(this@AuxioService, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(this@AuxioService, null) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(this@AuxioService, null) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(this@AuxioService) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(this@AuxioService) }) - } - return music - } - - private fun searchTo( - query: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary, - cb: (SearchEngine.Items) -> Unit - ) { - // TODO: Queue up searches rather than clobbering the last one - searchJob?.cancel() - searchJob = - searchScope.launch { - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists - ) - val results = searchEngine.search(items, query) - searchResultsCache[query] = results - cb(results) - } - } - - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = (page + 1) * pageSize - if (pageSize == 0 || start !in indices || end - 1 !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(page * pageSize, (page + 1) * pageSize).toMutableList() - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = - when (customCommand.customAction) { - ACTION_INC_REPEAT_MODE -> { - repeatMode(repeatMode.increment()) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - ACTION_INVERT_SHUFFLE -> { - shuffled(!player.shuffleModeEnabled) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - ACTION_EXIT -> { - endSession() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - else -> super.onCustomCommand(session, controller, customCommand, args) - } - - private fun updateCustomButtons() { - val actions = mutableListOf() - - when (playbackSettings.notificationAction) { - ActionMode.REPEAT -> { - actions.add( - CommandButton.Builder() - .setIconResId(playbackManager.repeatMode.icon) - .setDisplayName(getString(R.string.desc_change_repeat)) - .setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle())) - .build() - ) - } - - ActionMode.SHUFFLE -> { - actions.add( - CommandButton.Builder() - .setIconResId( - if (player.shuffleModeEnabled) R.drawable.ic_shuffle_on_24 - else R.drawable.ic_shuffle_off_24 - ) - .setDisplayName(getString(R.string.lbl_shuffle)) - .setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle())) - .build() - ) - } - - else -> {} - } - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_close_24) - .setDisplayName(getString(R.string.desc_exit)) - .setSessionCommand(SessionCommand(ACTION_EXIT, Bundle())) - .build() - ) - - mediaSession.setCustomLayout(actions) - } - - private fun deferSave() { - saveJob { - logD("Waiting for save buffer") - delay(SAVE_BUFFER) - yield() - logD("Committing saved state") - persistenceRepository.saveState(playbackManager.toSavedState()) - } - } - - private fun saveJob(block: suspend () -> Unit) { - currentSaveJob?.let { - logD("Discarding prior save job") - it.cancel() - } - currentSaveJob = saveScope.launch { block() } - } - - private fun broadcastAudioEffectAction(event: String) { - logD("Broadcasting AudioEffect event: $event") - sendBroadcast( - Intent(event) - .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - ) - } - - private fun endSession() { - // This session has ended, so we need to reset this flag for when the next - // session starts. - saveJob { - logD("Committing saved state") - persistenceRepository.saveState(playbackManager.toSavedState()) - withContext(Dispatchers.Main) { - // User could feasibly start playing again if they were fast enough, so - // we need to avoid stopping the foreground state if that's the case. - if (player.isPlaying) { - playbackManager.playing(false) - } - inPlayback = false - updateForeground(forMusic = false) - } - } - } - - /** - * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require - * an active [IntentFilter] to be registered. - */ - private inner class PlaybackReceiver : BroadcastReceiver() { - private var initialHeadsetPlugEventHandled = false - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - // --- SYSTEM EVENTS --- - - // Android has three different ways of handling audio plug events for some reason: - // 1. ACTION_HEADSET_PLUG, which only works with wired headsets - // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires - // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less - // a non-starter since both require me to display a permission prompt - // 3. Some internal framework thing that also handles bluetooth headsets - // Just use ACTION_HEADSET_PLUG. - AudioManager.ACTION_HEADSET_PLUG -> { - logD("Received headset plug event") - when (intent.getIntExtra("state", -1)) { - 0 -> pauseFromHeadsetPlug() - 1 -> playFromHeadsetPlug() - } - - initialHeadsetPlugEventHandled = true - } - - AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { - logD("Received Headset noise event") - pauseFromHeadsetPlug() - } - - // --- AUXIO EVENTS --- - ACTION_PLAY_PAUSE -> { - logD("Received play event") - playbackManager.playing(!playbackManager.progression.isPlaying) - } - - ACTION_INC_REPEAT_MODE -> { - logD("Received repeat mode event") - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - } - - ACTION_INVERT_SHUFFLE -> { - logD("Received shuffle event") - playbackManager.shuffled(!playbackManager.isShuffled) - } - - ACTION_SKIP_PREV -> { - logD("Received skip previous event") - playbackManager.prev() - } - - ACTION_SKIP_NEXT -> { - logD("Received skip next event") - playbackManager.next() - } - - ACTION_EXIT -> { - logD("Received exit event") - playbackManager.playing(false) - endSession() - } - - WidgetProvider.ACTION_WIDGET_UPDATE -> { - logD("Received widget update event") - widgetComponent.update() - } - } - } - - private fun playFromHeadsetPlug() { - // ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached, - // which would result in unexpected playback. Work around it by dropping the first - // call to this function, which should come from that Intent. - if (playbackSettings.headsetAutoplay && - playbackManager.currentSong != null && - initialHeadsetPlugEventHandled - ) { - logD("Device connected, resuming") - playbackManager.playing(true) - } - } - - private fun pauseFromHeadsetPlug() { - if (playbackManager.currentSong != null) { - logD("Device disconnected, pausing") - playbackManager.playing(false) - } - } - } - - companion object { - const val WAKELOCK_TIMEOUT_MS = 60 * 1000L - const val REINDEX_DELAY_MS = 500L - const val SAVE_BUFFER = 5000L - const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" - const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" - const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" - const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" - const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" - const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" - } -} - -class NeoPlayer( - val context: Context, - val player: ExoPlayer, - val musicRepository: MusicRepository, - val playbackManager: PlaybackStateManager, - val stateHolder: PlaybackStateHolder, - val commandFactory: PlaybackCommand.Factory, - val playbackSettings: PlaybackSettings, -) : ForwardingPlayer(player) { - var parent: MusicParent? = null - private set - - val audioSessionId: Int - get() = player.audioSessionId - - val durationMs: Long? - get() = - musicRepository.deviceLibrary?.let { - currentMediaItem?.mediaMetadata?.extras?.getLong("durationMs") - } - - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun getMediaMetadata(): MediaMetadata { - // TODO: Append parent to this for patched notification - return player.mediaMetadata - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks, - // since anything else (like newPlayback) will be calling directly on the player. As part - // of this, we expand the given MediaItems into the command that should be sent to the - // player. - val command = - if (mediaItems.size > 1) { - this.playMediaItemSelection(mediaItems, startIndex) - } else { - this.playSingleMediaItem(mediaItems.first()) - } - if (command != null) { - this.newPlayback(command) - player.seekTo(startPositionMs) - } else { - error("Invalid playback configuration") - } - } - - private fun playMediaItemSelection( - mediaItems: List, - startIndex: Int - ): PlaybackCommand? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary) - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - var index = startIndex - if (targetSong != null) { - while (songs.getOrNull(index)?.uid != targetSong.uid) { - index-- - } - } - return commandFactory.songs(songs, ShuffleMode.OFF) - } - - private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? { - val uid = ExternalUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is ExternalUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - - is ExternalUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun seekToNext() { - // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. - // Basically, you can't skip back and wrap around the queue, but you can skip forward and - // wrap around the queue, albeit playback will be paused. - if (repeatMode != REPEAT_MODE_OFF || hasNextMediaItem()) { - player.seekToNext() - if (!playbackSettings.rememberPause) { - player.play() - } - } else { - player.seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET) - // TODO: Dislike the UX implications of this, I feel should I bite the bullet - // and switch to dynamic skip enable/disable? - if (!playbackSettings.rememberPause) { - player.pause() - } - } - // Ack is handled in listener. - } - - override fun seekToPrevious() { - if (playbackSettings.rewindWithPrev) { - player.seekToPrevious() - } else { - player.seekToPreviousMediaItem() - } - if (!playbackSettings.rememberPause) { - player.play() - } - // Ack is handled in listener. - } - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) { - player.seekTo(mediaItemIndex, positionMs) - if (!playbackSettings.rememberPause) { - player.play() - } - // Ack handled in listener. - } - - override fun setRepeatMode(repeatMode: Int) { - player.setRepeatMode(repeatMode) - this.updatePauseOnRepeat() - playbackManager.ack(stateHolder, StateAck.RepeatModeChanged) - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - player.setShuffleModeEnabled(shuffleModeEnabled) - if (shuffleModeEnabled) { - // Have to manually refresh the shuffle seed and anchor it to the new current songs - player.setShuffleOrder(BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) - } - playbackManager.ack(stateHolder, StateAck.QueueReordered) - } - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - // Sanitize possible MediaBrowser-specific items - val items = mediaItems.mapNotNull { it.toSong(deviceLibrary)?.toMediaItem(context, null) } - if (items.isEmpty()) { - return - } - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(index) - val ack = if (index == player.nextMediaItemIndex) { - StateAck.PlayNext(fakeIndex + 1, items.size) - } else if (index >= mediaItemCount) { - // Add to queue - StateAck.AddToQueue(mediaItemCount, items.size) - } else { - // I really don't want to handle any other case right now and won't until I know - // they occured. - return - } - player.addMediaItems(index, items) - playbackManager.ack(stateHolder, ack) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - val fakeTo = indices.indexOf(newIndex) - val ack = StateAck.Move(fakeFrom, fakeTo) - player.moveMediaItem(currentIndex, newIndex) - playbackManager.ack(stateHolder, ack) - } - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - player.removeMediaItem(index) - val ack = StateAck.Remove(fakeAt) - playbackManager.ack(stateHolder, ack) - } - - fun newPlayback(command: PlaybackCommand) { - this.parent = command.parent - player.shuffleModeEnabled = shuffleModeEnabled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) - val startIndex = - command.song - ?.let { command.queue.indexOf(it) } - .also { check(it != -1) { "Start song not in queue" } } - if (command.shuffled) { - player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1)) - } - val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(shuffleModeEnabled) - player.seekTo(target, C.TIME_UNSET) - player.prepare() - player.play() - playbackManager.ack(stateHolder, StateAck.NewPlayback) - } - - fun repeatMode(repeatMode: RepeatMode) { - this.repeatMode = - when (repeatMode) { - RepeatMode.NONE -> REPEAT_MODE_OFF - RepeatMode.ALL -> REPEAT_MODE_ALL - RepeatMode.TRACK -> REPEAT_MODE_ONE - } - } - - fun goto(index: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[index] - this.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic - } - - fun playNext(songs: List, ack: StateAck.PlayNext) { - val currTimeline = player.currentTimeline - val nextIndex = - if (currTimeline.isEmpty) { - C.INDEX_UNSET - } else { - currTimeline.getNextWindowIndex( - currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled - ) - } - - if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) - } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) - } - playbackManager.ack(stateHolder, ack) - } - - fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) - playbackManager.ack(stateHolder, ack) - } - - fun move(from: Int, to: Int, ack: StateAck.Move) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueFrom = indices[from] - val trueTo = indices[to] - - when { - trueFrom > trueTo -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo + 1, trueFrom) - } - - trueTo > trueFrom -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo - 1, trueFrom) - } - } - playbackManager.ack(stateHolder, ack) - } - - fun remove(at: Int, ack: StateAck.Remove) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[at] - val songWillChange = currentMediaItemIndex == trueIndex - removeMediaItem(trueIndex) - if (songWillChange && !playbackSettings.rememberPause) { - play() - } - playbackManager.ack(stateHolder, ack) - } - - fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) { - this.parent = parent - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) - if (rawQueue.isShuffled) { - player.shuffleModeEnabled = true - player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - player.shuffleModeEnabled = false - } - player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) - player.prepare() - ack?.let { playbackManager.ack(stateHolder, it) } - } - - fun reset(ack: StateAck.NewPlayback) { - player.setMediaItems(listOf()) - playbackManager.ack(stateHolder, ack) - } - - fun updatePauseOnRepeat() { - player.pauseAtEndOfMediaItems = - repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat - } - - fun resolveQueue(): RawQueue { - val deviceLibrary = - musicRepository.deviceLibrary - // No library, cannot do anything. - ?: return RawQueue(emptyList(), emptyList(), 0) - val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } - val shuffledMapping = - if (shuffleModeEnabled) { - unscrambleQueueIndices() - } else { - emptyList() - } - return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, - shuffledMapping, - player.currentMediaItemIndex - ) - } - - private fun unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled - ) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled - ) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue - } -} - -private fun ExternalUID.Category.toMediaItem(context: Context): MediaItem { - val metadata = - MediaMetadata.Builder() - .setTitle(context.getString(nameRes)) - .setIsPlayable(false) - .setIsBrowsable(true) - .setMediaType(mediaType) - .build() - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() -} - -private fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(album.name.resolve(context)) - .setAlbumArtist(album.artists.resolveNames(context)) - .setTrackNumber(track) - .setDiscNumber(disc?.number) - .setGenre(genres.resolveNames(context)) - .setDisplayTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setRecordingYear(album.dates?.min?.year) - .setRecordingMonth(album.dates?.min?.month) - .setRecordingDay(album.dates?.min?.day) - .setReleaseYear(album.dates?.min?.year) - .setReleaseMonth(album.dates?.min?.month) - .setReleaseDay(album.dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) - .setIsPlayable(true) - .setIsBrowsable(false) - .setArtworkUri(album.coverUri.mediaStore) - .setExtras( - Bundle().apply { - putString("uid", externalUID.toString()) - putLong("durationMs", durationMs) - }) - .build() - return MediaItem.Builder() - .setUri(uri) - .setMediaId(externalUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -private fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(name.resolve(context)) - .setAlbumArtist(artists.resolveNames(context)) - .setRecordingYear(dates?.min?.year) - .setRecordingMonth(dates?.min?.month) - .setRecordingDay(dates?.min?.day) - .setReleaseYear(dates?.min?.year) - .setReleaseMonth(dates?.min?.month) - .setReleaseDay(dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(coverUri.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(true) - .setIsBrowsable(true) - .setGenre(genres.resolveNames(context)) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Genre.toMediaItem(context: Context): MediaItem { - val externalUID = ExternalUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Playlist.toMediaItem(context: Context): MediaItem { - val externalUID = ExternalUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { - val uid = ExternalUID.fromString(mediaId) ?: return null - return when (uid) { - is ExternalUID.Single -> { - deviceLibrary.findSong(uid.uid) - } - - is ExternalUID.Joined -> { - deviceLibrary.findSong(uid.childUid) - } - - is ExternalUID.Category -> null - } -} - -class NeoBitmapLoader -@Inject -constructor( - private val musicRepository: MusicRepository, - private val bitmapProvider: BitmapProvider -) : BitmapLoader { - override fun decodeBitmap(data: ByteArray): ListenableFuture { - TODO("Not yet implemented") - } - - override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { - TODO("Not yet implemented") - } - - override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val future = SettableFuture.create() - val song = - when (val uid = metadata.extras?.getString("uid")?.let { ExternalUID.fromString(it) }) { - is ExternalUID.Single -> deviceLibrary.findSong(uid.uid) - is ExternalUID.Joined -> deviceLibrary.findSong(uid.childUid) - else -> return null - } - ?: return null - bitmapProvider.load( - song, - object : BitmapProvider.Target { - override fun onCompleted(bitmap: Bitmap?) { - if (bitmap == null) { - future.setException(IllegalStateException("Bitmap is null")) - } else { - future.set(bitmap) - } - } - }) - return future - } -} - -sealed interface ExternalUID { - enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : - ExternalUID { - ROOT("root", R.string.info_app_name, null), - SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); - - override fun toString() = "$ID_CATEGORY:$id" - } - - data class Single(val uid: Music.UID) : ExternalUID { - override fun toString() = "$ID_ITEM:$uid" - } - - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : ExternalUID { - override fun toString() = "$ID_ITEM:$parentUid>$childUid" - } - - companion object { - const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" - const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" - - fun fromString(str: String): ExternalUID? { - val parts = str.split(":", limit = 2) - if (parts.size != 2) { - return null - } - return when (parts[0]) { - ID_CATEGORY -> - when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> null - } - - ID_ITEM -> { - val uids = parts[1].split(">", limit = 2) - if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { Single(it) } - } else { - Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } - } - } - } - - else -> return null - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt rename to app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt index 7bcd6118a..df1c1e604 100644 --- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.service +package org.oxycblt.auxio.ui import android.content.Context import androidx.annotation.StringRes diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index bc99380ea..9ffd7631f 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -28,11 +28,11 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.RemoteViews +import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW From a6cc38e43c1f643364c82c3064480c6fd85e4164 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Apr 2024 19:21:40 -0600 Subject: [PATCH 037/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 494092c61..0c9a5949c 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 494092c61db3c8e601dab15c547f21c3c1801deb +Subproject commit 0c9a5949c917338ad5f421c899e8041fc5b01643 From 74551e83ab88c1f65bd34d8b3ff5636ccbfc6c7a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Apr 2024 19:30:49 -0600 Subject: [PATCH 038/110] playback: fix being unable to exit fg --- .../java/org/oxycblt/auxio/AuxioService.kt | 20 +++++++++++-------- .../playback/service/MediaSessionPlayer.kt | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 58785e096..d37abca77 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -67,6 +67,7 @@ import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -232,10 +233,6 @@ class AuxioService : // --- INTERNAL --- private fun updateForeground(forMusic: Boolean) { - if (playbackManager.progression.isPlaying) { - inPlayback = true - } - if (inPlayback) { if (!forMusic) { val notification = @@ -287,7 +284,7 @@ class AuxioService : } else { // Not observing and done loading, exit foreground. logD("Exiting foreground") - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) } // Release our wake lock (if we were using it) wakeLock.releaseSafe() @@ -500,9 +497,11 @@ class AuxioService : updateCustomButtons() } - override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - super.onQueueReordered(queue, index, isShuffled) - updateCustomButtons() + override fun onProgressionChanged(progression: Progression) { + super.onProgressionChanged(progression) + if (progression.isPlaying) { + inPlayback = true + } } override fun onRepeatModeChanged(repeatMode: RepeatMode) { @@ -510,6 +509,11 @@ class AuxioService : updateCustomButtons() } + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + super.onQueueReordered(queue, index, isShuffled) + updateCustomButtons() + } + override fun onNotificationActionChanged() { super.onNotificationActionChanged() updateCustomButtons() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 1ea54f92a..f2c5011fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -44,11 +44,11 @@ import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.util.logD /** * A thin wrapper around the player instance that takes all the events I know MediaSession will send * and routes them to PlaybackStateManager so I know that they will work the way I want it to. + * * @author Alexander Capehart */ class MediaSessionPlayer( From bd890880a3f7c2703edf2b5365039a3f810f79da Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Apr 2024 19:47:56 -0600 Subject: [PATCH 039/110] playback: restore repeat modes again --- .../org/oxycblt/auxio/playback/state/PlaybackStateManager.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index c5231e0b0..d9364fe91 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -793,6 +793,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback) stateHolder.seekTo(savedState.positionMs) } + stateHolder.repeatMode(savedState.repeatMode) isInitialized = true } From 1f9f62b0dafcf42758f685554e88f97f711d7eab Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 Apr 2024 19:48:28 -0600 Subject: [PATCH 040/110] playback: fix wraparound with repeat once --- .../oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 3ae7b7edb..4159c8c46 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -236,7 +236,7 @@ class ExoPlaybackStateHolder( // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. // Basically, you can't skip back and wrap around the queue, but you can skip forward and // wrap around the queue, albeit playback will be paused. - if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) { + if (player.repeatMode == Player.REPEAT_MODE_ALL || player.hasNextMediaItem()) { player.seekToNext() if (!playbackSettings.rememberPause) { player.play() From 9b09572382676c1b0a36efada13935166d79459c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 14:36:56 -0600 Subject: [PATCH 041/110] playback: properly play media items from command By default it actually routes them to add them --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index d37abca77..787251ae8 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -444,6 +444,16 @@ class AuxioService : return Futures.immediateFuture(result) } + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture = + Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) + override fun onGetChildren( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -456,7 +466,8 @@ class AuxioService : musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let { LibraryResult.ofItemList(it, params) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + ?: LibraryResult.ofError>( + LibraryResult.RESULT_ERROR_BAD_VALUE) return Futures.immediateFuture(children) } From 1e0c7cebcf34211f6630f5ed92c0f817488293b4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 14:37:57 -0600 Subject: [PATCH 042/110] service: fix search endlessly loading --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 787251ae8..4627d7f41 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -479,7 +479,8 @@ class AuxioService : ): ListenableFuture> = waitScope .async { - musicMediaItemBrowser.prepareSearch(query) + val count = musicMediaItemBrowser.prepareSearch(query) + session.notifySearchResultChanged(browser, query, count, params) LibraryResult.ofVoid() } .asListenableFuture() From c69d3cac549feec9a82513819afd42712992c512 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:05:33 -0600 Subject: [PATCH 043/110] info: flag android auto support Technically possible now, have not been able to test. --- app/src/main/AndroidManifest.xml | 6 ++++++ app/src/main/res/xml/automotive_app_desc.xml | 4 ++++ 2 files changed, 10 insertions(+) create mode 100644 app/src/main/res/xml/automotive_app_desc.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba2a499c0..53c1b4e2b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,12 @@ android:enableOnBackInvokedCallback="true" tools:ignore="UnusedAttribute"> + + + + + + \ No newline at end of file From 43a8041d0aec3fcc3675646ce01532fe159e43a8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:06:55 -0600 Subject: [PATCH 044/110] build: update media to 1.3.1 --- .../java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt | 5 ++--- media | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt index 050166483..22d4cceff 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.image.service import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import androidx.media3.common.MediaMetadata import androidx.media3.common.util.BitmapLoader @@ -44,8 +43,8 @@ constructor( throw NotImplementedError() } - override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { - throw NotImplementedError() + override fun supportsMimeType(mimeType: String): Boolean { + return true } override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { diff --git a/media b/media index 0c9a5949c..53f6b76fd 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 0c9a5949c917338ad5f421c899e8041fc5b01643 +Subproject commit 53f6b76fda7afd72134990c86620f3b9ced37792 From 3b14c35c2dee2217c16859d225e9aa5fabdd79f5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:07:36 -0600 Subject: [PATCH 045/110] music: fix mediaitem pagination --- .../oxycblt/auxio/music/service/MusicMediaItemBrowser.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt index 00876951f..d56263a0b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -235,11 +235,11 @@ constructor( return this } val start = page * pageSize - val end = (page + 1) * pageSize - if (pageSize == 0 || start !in indices || end - 1 !in indices) { + val end = min((page + 1) * pageSize, size) // Tolerate partial page queries + if (pageSize == 0 || start !in indices) { // These pages are probably invalid. Hopefully this won't backfire. return null } - return subList(page * pageSize, (page + 1) * pageSize).toMutableList() + return subList(start, end).toMutableList() } } From 33916deb5c4f6dabe70c0ccf8b905595f6575b00 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:08:34 -0600 Subject: [PATCH 046/110] playback: remove joined uids from parents Not needed. --- .../music/service/MediaItemTranslation.kt | 18 ++++----------- .../music/service/MusicMediaItemBrowser.kt | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index fcf65715a..97390fe8a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -88,13 +88,8 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .build() } -fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.Single(uid) - } else { - MediaSessionUID.Joined(parent.uid, uid) - } +fun Album.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) val metadata = MediaMetadata.Builder() .setTitle(name.resolve(context)) @@ -119,13 +114,8 @@ fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { .build() } -fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.Single(uid) - } else { - MediaSessionUID.Joined(parent.uid, uid) - } +fun Artist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) val metadata = MediaMetadata.Builder() .setTitle(name.resolve(context)) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt index d56263a0b..0ad310744 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -82,8 +82,8 @@ constructor( ?: return null return when (music) { - is Album -> music.toMediaItem(context, null) - is Artist -> music.toMediaItem(context, null) + is Album -> music.toMediaItem(context) + is Artist -> music.toMediaItem(context) is Genre -> music.toMediaItem(context) is Playlist -> music.toMediaItem(context) is Song -> music.toMediaItem(context, null) @@ -120,9 +120,9 @@ constructor( MediaSessionUID.Category.SONGS -> deviceLibrary.songs.map { it.toMediaItem(context, null) } MediaSessionUID.Category.ALBUMS -> - deviceLibrary.albums.map { it.toMediaItem(context, null) } + deviceLibrary.albums.map { it.toMediaItem(context) } MediaSessionUID.Category.ARTISTS -> - deviceLibrary.artists.map { it.toMediaItem(context, null) } + deviceLibrary.artists.map { it.toMediaItem(context) } MediaSessionUID.Category.GENRES -> deviceLibrary.genres.map { it.toMediaItem(context) } MediaSessionUID.Category.PLAYLISTS -> @@ -130,12 +130,14 @@ constructor( } } is MediaSessionUID.Single -> { - getChildMediaItems(mediaSessionUID.uid) ?: return null + getChildMediaItems(mediaSessionUID.uid) } is MediaSessionUID.Joined -> { - getChildMediaItems(mediaSessionUID.childUid) ?: return null + getChildMediaItems(mediaSessionUID.childUid) + } + null -> { + return null } - null -> return null } } @@ -145,7 +147,7 @@ constructor( item.songs.map { it.toMediaItem(context, item) } } is Artist -> { - (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context, item) } + + (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context) } + item.songs.map { it.toMediaItem(context, item) } } is Genre -> { @@ -202,10 +204,10 @@ constructor( music.addAll(songs.map { it.toMediaItem(context, null) }) } if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context, null) }) + music.addAll(albums.map { it.toMediaItem(context) }) } if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context, null) }) + music.addAll(artists.map { it.toMediaItem(context) }) } if (genres != null) { music.addAll(genres.map { it.toMediaItem(context) }) From fb15791c2f5e26a7689973a6d42ab6f0ad827e9d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:09:00 -0600 Subject: [PATCH 047/110] playback: backfill Forgot to add these to other commits --- .../music/service/MusicMediaItemBrowser.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt index 0ad310744..83831f510 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -22,6 +22,7 @@ import android.content.Context import androidx.media3.common.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlin.math.min import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -161,18 +162,20 @@ constructor( } } - suspend fun prepareSearch(query: String) { + suspend fun prepareSearch(query: String): Int { val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (deviceLibrary == null || userLibrary == null) { - return + return 0 } if (query.isEmpty()) { - return + return 0 } - searchTo(query, deviceLibrary, userLibrary).await() + val deferred = searchTo(query, deviceLibrary, userLibrary) + searchResults[query] = deferred + return deferred.await().count() } suspend fun getSearchResult( @@ -218,6 +221,26 @@ constructor( return music } + private fun SearchEngine.Items.count(): Int { + var count = 0 + if (songs != null) { + count += songs.size + } + if (albums != null) { + count += albums.size + } + if (artists != null) { + count += artists.size + } + if (genres != null) { + count += genres.size + } + if (playlists != null) { + count += playlists.size + } + return count + } + private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = searchScope.async { val items = From 0ca928a477472db6d813b02dbd9434be8705608b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:09:25 -0600 Subject: [PATCH 048/110] playback: tweak media3 command surface --- .../playback/service/MediaSessionPlayer.kt | 81 +++++++++---------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index f2c5011fd..db7d5c408 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -44,10 +44,17 @@ import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE /** - * A thin wrapper around the player instance that takes all the events I know MediaSession will send - * and routes them to PlaybackStateManager so I know that they will work the way I want it to. + * A thin wrapper around the player instance that drastically reduces the command surface and + * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands + * that Media3 will throw at me will be handled in a predictable way, rather than just clobbering + * the playback state. Largely limited to the legacy media APIs. + * + * I'll add more support as I go along when I can confirm that apps will use the Media3 API and + * send more advanced commands. * * @author Alexander Capehart */ @@ -70,45 +77,34 @@ class MediaSessionPlayer( command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) } + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + if (!resetPosition) { + error("Playing MediaItems with custom position parameters is not supported") + } + + setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) + } + override fun setMediaItems( mediaItems: MutableList, startIndex: Int, startPositionMs: Long ) { - // We assume the only people calling this method are going to be the MediaSession callbacks, - // since anything else (like newPlayback) will be calling directly on the player. As part - // of this, we expand the given MediaItems into the command that should be sent to the - // player. - val command = - if (mediaItems.size > 1) { - this.playMediaItemSelection(mediaItems, startIndex) - } else { - this.playSingleMediaItem(mediaItems.first()) - } + // We assume the only people calling this method are going to be the MediaSession callbacks. + // As part of this, we expand the given MediaItems into the command that should be sent to + // the player. + if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) { + error("Playing MediaItems with custom position parameters is not supported") + } + if (mediaItems.size != 1) { + error("Playing multiple MediaItems is not supported") + } + val command = expandMediaItemIntoCommand(mediaItems.first()) requireNotNull(command) { "Invalid playback configuration" } playbackManager.play(command) - if (startPositionMs != C.TIME_UNSET) { - playbackManager.seekTo(startPositionMs) - } } - private fun playMediaItemSelection( - mediaItems: List, - startIndex: Int - ): PlaybackCommand? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary) - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - var index = startIndex - if (targetSong != null) { - while (songs.getOrNull(index)?.uid != targetSong.uid) { - index-- - } - } - return commandFactory.songs(songs, ShuffleMode.OFF) - } - - private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? { + private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? { val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null val music: Music var parent: MusicParent? = null @@ -143,9 +139,9 @@ class MediaSessionPlayer( null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) } - override fun setPlayWhenReady(playWhenReady: Boolean) { - playbackManager.playing(playWhenReady) - } + override fun play() = playbackManager.playing(true) + + override fun pause() = playbackManager.playing(false) override fun setRepeatMode(repeatMode: Int) { val appRepeatMode = @@ -243,9 +239,6 @@ class MediaSessionPlayer( override fun setMediaItems(mediaItems: MutableList) = notAllowed() - override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) = - notAllowed() - override fun addMediaItem(mediaItem: MediaItem) = notAllowed() override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() @@ -280,14 +273,12 @@ class MediaSessionPlayer( @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() - override fun play() = playbackManager.playing(true) - - override fun pause() = playbackManager.playing(false) - override fun prepare() = notAllowed() override fun release() = notAllowed() + override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() + override fun stop() = notAllowed() override fun hasNextMediaItem() = notAllowed() @@ -337,7 +328,11 @@ class MediaSessionPlayer( override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() - private fun notAllowed(): Nothing = error("MediaSession unexpectedly called this method") + private fun notAllowed(): Nothing { + logD("MediaSession unexpectedly called this method") + logE(Exception().stackTraceToString()) + error("MediaSession unexpectedly called this method") + } } fun Player.unscrambleQueueIndices(): List { From 24097af28c9d4243bc68769a9bba0afef2288f03 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:09:44 -0600 Subject: [PATCH 049/110] playback: cleanup --- .../org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt | 4 +++- .../org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt index 83831f510..bf5e5a519 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -55,11 +55,13 @@ constructor( } fun release() { + browserJob.cancel() musicRepository.removeUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary) { + val deviceLibrary = musicRepository.deviceLibrary + if (changes.deviceLibrary && deviceLibrary != null) { for (entry in searchResults.entries) { entry.value.cancel() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index db7d5c408..0e52d38b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -30,6 +30,7 @@ import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters +import java.lang.Exception import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre From 0c3362bc5436627452abee7058c2bea1c683003d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:22:39 -0600 Subject: [PATCH 050/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 53f6b76fd..cd99a7127 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 53f6b76fda7afd72134990c86620f3b9ced37792 +Subproject commit cd99a7127d1eb748243ae119510e838f338d5062 From 9b972e5d9205d3172c0d0eba390d861430054105 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:32:20 -0600 Subject: [PATCH 051/110] actions: band-aid submodule issue --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f106a3aac..763732ee5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -14,7 +14,7 @@ jobs: - name: Clone repository uses: actions/checkout@v3 - name: Clone submodules - run: git submodule update --init --recursive + run: git submodule update --init --recursive --remote - name: Set up JDK 17 uses: actions/setup-java@v3 with: From 02877972af333873cee57d5870934b794c082417 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 15:37:34 -0600 Subject: [PATCH 052/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index cd99a7127..ed6494429 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit cd99a7127d1eb748243ae119510e838f338d5062 +Subproject commit ed6494429db08fb8bb5dbeea8b505a755a6ad1a5 From be23208f72f920b2cf46c6bb400f79b4c04fa1d1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 23:44:35 -0600 Subject: [PATCH 053/110] service: break into components --- .../java/org/oxycblt/auxio/AuxioService.kt | 575 ++---------------- .../auxio/image/service/CoilBitmapLoader.kt | 2 +- .../auxio/music/service/IndexerComponent.kt | 184 ++++++ .../music/service/IndexerNotifications.kt | 56 +- ...ediaItemBrowser.kt => MediaItemBrowser.kt} | 37 +- .../music/service/MediaItemTranslation.kt | 4 + .../music/service/SystemContentObserver.kt | 79 +++ .../service/ExoPlaybackStateHolder.kt | 18 + .../playback/service/MediaSessionPlayer.kt | 14 +- .../service/MediaSessionServiceFragment.kt | 254 ++++++++ .../service/SystemPlaybackReciever.kt | 153 ++++- .../playback/state/PlaybackStateHolder.kt | 5 + .../playback/state/PlaybackStateManager.kt | 15 +- .../auxio/ui/ForegroundServiceNotification.kt | 71 --- .../oxycblt/auxio/widgets/WidgetProvider.kt | 14 +- 15 files changed, 826 insertions(+), 655 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt rename app/src/main/java/org/oxycblt/auxio/music/service/{MusicMediaItemBrowser.kt => MediaItemBrowser.kt} (88%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 4627d7f41..74ce64c14 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -19,575 +19,72 @@ package org.oxycblt.auxio import android.annotation.SuppressLint -import android.app.Notification -import android.content.Context import android.content.Intent -import android.database.ContentObserver -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.provider.MediaStore import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.media3.common.MediaItem -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaNotification import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import coil.ImageLoader -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.guava.asListenableFuture -import org.oxycblt.auxio.image.service.NeoBitmapLoader -import org.oxycblt.auxio.music.IndexingProgress -import org.oxycblt.auxio.music.IndexingState -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.fs.contentResolverSafe -import org.oxycblt.auxio.music.service.IndexingNotification -import org.oxycblt.auxio.music.service.MusicMediaItemBrowser -import org.oxycblt.auxio.music.service.ObservingNotification -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder -import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Progression -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.util.getSystemServiceCompat -import org.oxycblt.auxio.util.logD - -// TODO: Android Auto Hookup -// TODO: Custom notif +import org.oxycblt.auxio.music.service.IndexingServiceFragment +import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment @AndroidEntryPoint -class AuxioService : - MediaLibraryService(), - MediaLibrarySession.Callback, - MusicRepository.IndexingWorker, - MusicRepository.IndexingListener, - MusicRepository.UpdateListener, - MusicSettings.Listener, - PlaybackStateManager.Listener, - PlaybackSettings.Listener { - private val serviceJob = Job() - private var inPlayback = false +class AuxioService : MediaLibraryService(), ForegroundListener { + @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment - @Inject lateinit var musicRepository: MusicRepository - @Inject lateinit var musicSettings: MusicSettings - private lateinit var indexingNotification: IndexingNotification - private lateinit var observingNotification: ObservingNotification - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var indexerContentObserver: SystemContentObserver - private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) - private var currentIndexJob: Job? = null - - @Inject lateinit var playbackManager: PlaybackStateManager - @Inject lateinit var playbackSettings: PlaybackSettings - @Inject lateinit var systemReceiver: SystemPlaybackReceiver - @Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory - private lateinit var exoHolder: ExoPlaybackStateHolder - - @Inject lateinit var bitmapLoader: NeoBitmapLoader - @Inject lateinit var imageLoader: ImageLoader - - @Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser - private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default) - private lateinit var mediaSession: MediaLibrarySession + @Inject lateinit var indexingFragment: IndexingServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - - indexingNotification = IndexingNotification(this) - observingNotification = ObservingNotification(this) - wakeLock = - getSystemServiceCompat(PowerManager::class) - .newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") - - exoHolder = exoHolderFactory.create() - - mediaSession = - MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this) - .setBitmapLoader(bitmapLoader) - .build() - - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - indexerContentObserver = SystemContentObserver() - - setMediaNotificationProvider( - DefaultMediaNotificationProvider.Builder(this) - .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) - .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") - .setChannelName(R.string.lbl_playback) - .build() - .also { it.setSmallIcon(R.drawable.ic_auxio_24) }) - addSession(mediaSession) - updateCustomButtons() - - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - exoHolder.attach() - playbackManager.addListener(this) - playbackSettings.registerListener(this) - - ContextCompat.registerReceiver( - this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) - - musicMediaItemBrowser.attach() - musicSettings.registerListener(this) - musicRepository.addUpdateListener(this) - musicRepository.addIndexingListener(this) - musicRepository.registerWorker(this) + mediaSessionFragment.attach(this, this) + indexingFragment.attach(this) } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - if (!playbackManager.progression.isPlaying) { - // Stop the service if not playing, continue playing in the background - // otherwise. - endSession() - } + mediaSessionFragment.handleTaskRemoved() } override fun onDestroy() { super.onDestroy() - // De-initialize core service components first. - serviceJob.cancel() - wakeLock.releaseSafe() - // Then cancel the listener-dependent components to ensure that stray reloading - // events will not occur. - indexerContentObserver.release() - exoHolder.release() - musicSettings.unregisterListener(this) - musicRepository.removeUpdateListener(this) - musicRepository.removeIndexingListener(this) - musicRepository.unregisterWorker(this) - - // Pause just in case this destruction was unexpected. - playbackManager.playing(false) - playbackManager.unregisterStateHolder(exoHolder) - playbackSettings.unregisterListener(this) - - removeSession(mediaSession) - mediaSession.release() - unregisterReceiver(systemReceiver) - exoHolder.release() + indexingFragment.release() + mediaSessionFragment.release() } - // --- INDEXER OVERRIDES --- - - override fun requestIndex(withCache: Boolean) { - logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") - // Cancel the previous music loading job. - currentIndexJob?.cancel() - // Start a new music loading job on a co-routine. - currentIndexJob = musicRepository.index(this, withCache) - } - - override val workerContext: Context - get() = this - - override val scope = indexScope - - override fun onIndexingStateChanged() { - updateForeground(forMusic = true) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - logD("Music changed, updating shared objects") - // Wipe possibly-invalidated outdated covers - imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - savedState.copy( - heap = - savedState.heap.map { song -> - song?.let { deviceLibrary.findSong(it.uid) } - }), - true) - } - } - - // --- INTERNAL --- - - private fun updateForeground(forMusic: Boolean) { - if (inPlayback) { - if (!forMusic) { - val notification = - mediaNotificationProvider.createNotification( - mediaSession, - mediaSession.customLayout, - mediaNotificationManager.actionFactory) { notification -> - postMediaNotification(notification, mediaSession) - } - postMediaNotification(notification, mediaSession) - } - return - } - - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - updateLoadingForeground(state.progress) - } else { - updateIdleForeground() - } - } - - private fun updateLoadingForeground(progress: IndexingProgress) { - // When loading, we want to enter the foreground state so that android does - // not shut off the loading process. Note that while we will always post the - // notification when initially starting, we will not update the notification - // unless it indicates that it has changed. - val changed = indexingNotification.updateIndexingState(progress) - if (changed) { - logD("Notification changed, re-posting notification") - startForeground(indexingNotification.code, indexingNotification.build()) - } - // Make sure we can keep the CPU on while loading music - wakeLock.acquireSafe() - } - - private fun updateIdleForeground() { - if (musicSettings.shouldBeObserving) { - // There are a few reasons why we stay in the foreground with automatic rescanning: - // 1. Newer versions of Android have become more and more restrictive regarding - // how a foreground service starts. Thus, it's best to go foreground now so that - // we can go foreground later. - // 2. If a non-foreground service is killed, the app will probably still be alive, - // and thus the music library will not be updated at all. - // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need - // this anymore, or at least I only have to use it when the app task is not removed. - logD("Need to observe, staying in foreground") - startForeground(observingNotification.code, observingNotification.build()) - } else { - // Not observing and done loading, exit foreground. - logD("Exiting foreground") - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - } - // Release our wake lock (if we were using it) - wakeLock.releaseSafe() - } - - /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.acquireSafe() { - // Avoid unnecessary acquire calls. - if (!wakeLock.isHeld) { - logD("Acquiring wake lock") - // Time out after a minute, which is the average music loading time for a medium-sized - // library. If this runs out, we will re-request the lock, and if music loading is - // shorter than the timeout, it will be released early. - acquire(WAKELOCK_TIMEOUT_MS) - } - } - - /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.releaseSafe() { - // Avoid unnecessary release calls. - if (wakeLock.isHeld) { - logD("Releasing wake lock") - release() - } - } - - // --- SETTING CALLBACKS --- - - override fun onIndexingSettingChanged() { - // Music loading configuration changed, need to reload music. - requestIndex(true) - } - - override fun onObservingChanged() { - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (currentIndexJob == null) { - logD("Not loading, updating idle session") - updateForeground(forMusic = false) - } - } - - /** - * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior - * known to the user as automatic rescanning. The active (and not passive) nature of observing - * the database is what requires [IndexerService] to stay foreground when this is enabled. - */ - private inner class SystemContentObserver : - ContentObserver(Handler(Looper.getMainLooper())), Runnable { - private val handler = Handler(Looper.getMainLooper()) - - init { - contentResolverSafe.registerContentObserver( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) - } - - /** - * Release this instance, preventing it from further observing the database and cancelling - * any pending update events. - */ - fun release() { - handler.removeCallbacks(this) - contentResolverSafe.unregisterContentObserver(this) - } - - override fun onChange(selfChange: Boolean) { - // Batch rapid-fire updates to the library into a single call to run after 500ms - handler.removeCallbacks(this) - handler.postDelayed(this, REINDEX_DELAY_MS) - } - - override fun run() { - // Check here if we should even start a reindex. This is much less bug-prone than - // registering and de-registering this component as this setting changes. - if (musicSettings.shouldBeObserving) { - logD("MediaStore changed, starting re-index") - requestIndex(true) - } - } - } - - // --- SERVICE MANAGEMENT --- - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSession + mediaSessionFragment.mediaSession override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - logD("Notification update requested") - updateForeground(forMusic = false) + updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - private fun postMediaNotification(notification: MediaNotification, session: MediaSession) { - // Pulled from MediaNotificationManager: Need to specify MediaSession token manually - // in notification - val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token - notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) - startForeground(notification.notificationId, notification.notification) - } - - // --- MEDIASESSION CALLBACKS --- - - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult { - val sessionCommands = - ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() - .add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_EXIT, Bundle.EMPTY)) - .build() - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .build() - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = - when (customCommand.customAction) { - ACTION_INC_REPEAT_MODE -> { - logD(playbackManager.repeatMode.increment()) - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - ACTION_INVERT_SHUFFLE -> { - playbackManager.shuffled(!playbackManager.isShuffled) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - ACTION_EXIT -> { - endSession() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - else -> super.onCustomCommand(session, controller, customCommand, args) - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: LibraryParams? - ): ListenableFuture> = - Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params)) - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val result = - musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(result) - } - - override fun onSetMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture = - Futures.immediateFuture( - MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val children = - musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError>( - LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(children) - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: LibraryParams? - ): ListenableFuture> = - waitScope - .async { - val count = musicMediaItemBrowser.prepareSearch(query) - session.notifySearchResultChanged(browser, query, count, params) - LibraryResult.ofVoid() - } - .asListenableFuture() - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ) = - waitScope - .async { - musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) + override fun updateForeground(change: ForegroundListener.Change) { + if (mediaSessionFragment.hasNotification()) { + if (change == ForegroundListener.Change.MEDIA_SESSION) { + mediaSessionFragment.createNotification { + startForeground(it.notificationId, it.notification) + } + } + // Nothing changed, but don't show anything music related since we can always + // index during playback. + } else { + indexingFragment.createNotification { + if (it != null) { + startForeground(it.code, it.build()) + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) } - .asListenableFuture() - - // --- BUTTON MANAGEMENT --- - - override fun onPauseOnRepeatChanged() { - super.onPauseOnRepeatChanged() - updateCustomButtons() - } - - override fun onProgressionChanged(progression: Progression) { - super.onProgressionChanged(progression) - if (progression.isPlaying) { - inPlayback = true } } - - override fun onRepeatModeChanged(repeatMode: RepeatMode) { - super.onRepeatModeChanged(repeatMode) - updateCustomButtons() - } - - override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - super.onQueueReordered(queue, index, isShuffled) - updateCustomButtons() - } - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - updateCustomButtons() - } - - private fun updateCustomButtons() { - val actions = mutableListOf() - - when (playbackSettings.notificationAction) { - ActionMode.REPEAT -> { - actions.add( - CommandButton.Builder() - .setIconResId(playbackManager.repeatMode.icon) - .setDisplayName(getString(R.string.desc_change_repeat)) - .setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle())) - .build()) - } - ActionMode.SHUFFLE -> { - actions.add( - CommandButton.Builder() - .setIconResId( - if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 - else R.drawable.ic_shuffle_off_24) - .setDisplayName(getString(R.string.lbl_shuffle)) - .setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle())) - .build()) - } - else -> {} - } - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_close_24) - .setDisplayName(getString(R.string.desc_exit)) - .setSessionCommand(SessionCommand(ACTION_EXIT, Bundle())) - .build()) - - mediaSession.setCustomLayout(actions) - } - - private fun endSession() { - // This session has ended, so we need to reset this flag for when the next - // session starts. - exoHolder.save { - // User could feasibly start playing again if they were fast enough, so - // we need to avoid stopping the foreground state if that's the case. - if (playbackManager.progression.isPlaying) { - playbackManager.playing(false) - } - inPlayback = false - updateForeground(forMusic = false) - } - } - - companion object { - const val WAKELOCK_TIMEOUT_MS = 60 * 1000L - const val REINDEX_DELAY_MS = 500L - const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" - const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" - const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" - const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" - const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" - const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" - } +} + +interface ForegroundListener { + fun updateForeground(change: Change) + + enum class Change { + MEDIA_SESSION, + INDEXER + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt index 22d4cceff..58e8b609d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID -class NeoBitmapLoader +class MediaSessionBitmapLoader @Inject constructor( private val musicRepository: MusicRepository, diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt new file mode 100644 index 000000000..401b49145 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024 Auxio Project + * IndexerComponent.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.PowerManager +import coil.ImageLoader +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +class IndexingServiceFragment +@Inject +constructor( + @ApplicationContext override val workerContext: Context, + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val contentObserver: SystemContentObserver, + private val imageLoader: ImageLoader +) : + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { + private val indexJob = Job() + private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + private val indexingNotification = IndexingNotification(workerContext) + private val observingNotification = ObservingNotification(workerContext) + private var foregroundListener: ForegroundListener? = null + private val wakeLock = + workerContext + .getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") + + fun attach(listener: ForegroundListener) { + foregroundListener = listener + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + contentObserver.attach() + } + + fun release() { + contentObserver.release() + musicSettings.registerListener(this) + musicRepository.addIndexingListener(this) + musicRepository.addUpdateListener(this) + musicRepository.removeIndexingListener(this) + foregroundListener = null + } + + fun createNotification(post: (IndexerNotification?) -> Unit) { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + // There are a few reasons why we stay in the foreground with automatic rescanning: + // 1. Newer versions of Android have become more and more restrictive regarding + // how a foreground service starts. Thus, it's best to go foreground now so that + // we can go foreground later. + // 2. If a non-foreground service is killed, the app will probably still be alive, + // and thus the music library will not be updated at all. + val changed = indexingNotification.updateIndexingState(state.progress) + if (changed) { + post(indexingNotification) + } + } else if (musicSettings.shouldBeObserving) { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + post(observingNotification) + } else { + post(null) + } + } + + override fun requestIndex(withCache: Boolean) { + logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") + // Cancel the previous music loading job. + currentIndexJob?.cancel() + // Start a new music loading job on a co-routine. + currentIndexJob = musicRepository.index(this, withCache) + } + + override val scope = indexScope + + override fun onIndexingStateChanged() { + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + wakeLock.acquireSafe() + } else { + wakeLock.releaseSafe() + } + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + logD("Music changed, updating shared objects") + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } + } + + override fun onIndexingSettingChanged() { + super.onIndexingSettingChanged() + musicRepository.requestIndex(true) + } + + override fun onObservingChanged() { + super.onObservingChanged() + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (currentIndexJob == null) { + logD("Not loading, updating idle session") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } + } + + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.acquireSafe() { + // Avoid unnecessary acquire calls. + if (!wakeLock.isHeld) { + logD("Acquiring wake lock") + // Time out after a minute, which is the average music loading time for a medium-sized + // library. If this runs out, we will re-request the lock, and if music loading is + // shorter than the timeout, it will be released early. + acquire(WAKELOCK_TIMEOUT_MS) + } + } + + /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.releaseSafe() { + // Avoid unnecessary release calls. + if (wakeLock.isHeld) { + logD("Releasing wake lock") + release() + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index 2b1524fdf..d857ab32b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -20,23 +20,64 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress -import org.oxycblt.auxio.ui.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * A dynamic [ForegroundServiceNotification] that shows the current music loading state. + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class IndexerNotification(context: Context, info: ChannelInfo) : + NotificationCompat.Builder(context, info.id) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + // Set up the notification channel. Foreground notifications are non-substantial, and + // thus make no sense to have lights, vibration, or lead to a notification badge. + val channel = + NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(info.nameRes)) + .setLightsEnabled(false) + .setVibrationEnabled(false) + .setShowBadge(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + /** + * The code used to identify this notification. + * + * @see NotificationManagerCompat.notify + */ + abstract val code: Int + + /** + * Reduced representation of a [NotificationChannelCompat]. + * + * @param id The ID of the channel. + * @param nameRes A string resource ID corresponding to the human-readable name of this channel. + */ + data class ChannelInfo(val id: String, @StringRes val nameRes: Int) +} + +/** + * A dynamic [IndexerNotification] that shows the current music loading state. * * @param context [Context] required to create the notification. * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - ForegroundServiceNotification(context, indexerChannel) { + IndexerNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -92,13 +133,12 @@ class IndexingNotification(private val context: Context) : } /** - * A static [ForegroundServiceNotification] that signals to the user that the app is currently - * monitoring the music library for changes. + * A static [IndexerNotification] that signals to the user that the app is currently monitoring the + * music library for changes. * * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : - ForegroundServiceNotification(context, indexerChannel) { +class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -116,5 +156,5 @@ class ObservingNotification(context: Context) : /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val indexerChannel = - ForegroundServiceNotification.ChannelInfo( + IndexerNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt similarity index 88% rename from app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index bf5e5a519..93f383917 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * MusicMediaItemBrowser.kt is part of Auxio. + * MediaItemBrowser.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.search.SearchEngine -class MusicMediaItemBrowser +class MediaItemBrowser @Inject constructor( @ApplicationContext private val context: Context, @@ -49,19 +49,42 @@ constructor( private val browserJob = Job() private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) private val searchResults = mutableMapOf>() + private var invalidator: Invalidator? = null - fun attach() { + interface Invalidator { + fun invalidate(ids: List) + } + + fun attach(invalidator: Invalidator) { + this.invalidator = invalidator musicRepository.addUpdateListener(this) } fun release() { browserJob.cancel() + invalidator = null musicRepository.removeUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary + var invalidateSearch = false if (changes.deviceLibrary && deviceLibrary != null) { + val ids = + MediaSessionUID.Category.IMPORTANT + + deviceLibrary.albums.map { MediaSessionUID.Single(it.uid) } + + deviceLibrary.artists.map { MediaSessionUID.Single(it.uid) } + + deviceLibrary.genres.map { MediaSessionUID.Single(it.uid) } + invalidator?.invalidate(ids.map { it.toString() }) + invalidateSearch = true + } + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + val ids = userLibrary.playlists.map { MediaSessionUID.Single(it.uid) } + invalidator?.invalidate(ids.map { it.toString() }) + invalidateSearch = true + } + if (invalidateSearch) { for (entry in searchResults.entries) { entry.value.cancel() } @@ -113,13 +136,7 @@ constructor( is MediaSessionUID.Category -> { when (mediaSessionUID) { MediaSessionUID.Category.ROOT -> - listOf( - MediaSessionUID.Category.SONGS, - MediaSessionUID.Category.ALBUMS, - MediaSessionUID.Category.ARTISTS, - MediaSessionUID.Category.GENRES, - MediaSessionUID.Category.PLAYLISTS) - .map { it.toMediaItem(context) } + MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } MediaSessionUID.Category.SONGS -> deviceLibrary.songs.map { it.toMediaItem(context, null) } MediaSessionUID.Category.ALBUMS -> diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 97390fe8a..2a6175627 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -215,6 +215,10 @@ sealed interface MediaSessionUID { PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); override fun toString() = "$ID_CATEGORY:$id" + + companion object { + val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) + } } data class Single(val uid: Music.UID) : MediaSessionUID { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt new file mode 100644 index 000000000..b44c9785c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemContentObserver.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.fs.contentResolverSafe +import org.oxycblt.auxio.util.logD + +/** + * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior known + * to the user as automatic rescanning. The active (and not passive) nature of observing the + * database is what requires [IndexerService] to stay foreground when this is enabled. + */ +class SystemContentObserver +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ContentObserver(Handler(Looper.getMainLooper())), Runnable { + private val handler = Handler(Looper.getMainLooper()) + + fun attach() { + context.contentResolverSafe.registerContentObserver( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) + } + + /** + * Release this instance, preventing it from further observing the database and cancelling any + * pending update events. + */ + fun release() { + handler.removeCallbacks(this) + context.contentResolverSafe.unregisterContentObserver(this) + } + + override fun onChange(selfChange: Boolean) { + // Batch rapid-fire updates to the library into a single call to run after 500ms + handler.removeCallbacks(this) + handler.postDelayed(this, REINDEX_DELAY_MS) + } + + override fun run() { + // Check here if we should even start a reindex. This is much less bug-prone than + // registering and de-registering this component as this setting changes. + if (musicSettings.shouldBeObserving) { + logD("MediaStore changed, starting re-index") + musicRepository.requestIndex(true) + } + } + + private companion object { + const val REINDEX_DELAY_MS = 500L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 4159c8c46..f7dada9eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -81,6 +81,9 @@ class ExoPlaybackStateHolder( private var currentSaveJob: Job? = null private var openAudioEffectSession = false + var sessionOngoing = false + private set + fun attach() { player.addListener(this) playbackManager.registerStateHolder(this) @@ -358,6 +361,20 @@ class ExoPlaybackStateHolder( ack?.let { playbackManager.ack(this, it) } } + override fun endSession() { + // This session has ended, so we need to reset this flag for when the next + // session starts. + save { + // User could feasibly start playing again if they were fast enough, so + // we need to avoid stopping the foreground state if that's the case. + if (playbackManager.progression.isPlaying) { + playbackManager.playing(false) + } + sessionOngoing = false + playbackManager.ack(this, StateAck.SessionEnded) + } + } + override fun reset(ack: StateAck.NewPlayback) { player.setMediaItems(listOf()) playbackManager.ack(this, ack) @@ -372,6 +389,7 @@ class ExoPlaybackStateHolder( if (player.playWhenReady) { // Mark that we have started playing so that the notification can now be posted. logD("Player has started playing") + sessionOngoing = true if (!openAudioEffectSession) { // Convention to start an audioeffect session on play/pause rather than // start/stop diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 0e52d38b3..fb662ef2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -50,12 +50,12 @@ import org.oxycblt.auxio.util.logE /** * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands - * that Media3 will throw at me will be handled in a predictable way, rather than just clobbering - * the playback state. Largely limited to the legacy media APIs. + * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that + * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the + * playback state. Largely limited to the legacy media APIs. * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and - * send more advanced commands. + * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send + * more advanced commands. * * @author Alexander Capehart */ @@ -229,6 +229,8 @@ class MediaSessionPlayer( override fun removeMediaItems(fromIndex: Int, toIndex: Int) = error("Any multi-item queue removal is unsupported") + override fun stop() = playbackManager.endSession() + // These methods I don't want MediaSession calling in any way since they'll do insane things // that I'm not tracking. If they do call them, I will know. @@ -280,8 +282,6 @@ class MediaSessionPlayer( override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - override fun stop() = notAllowed() - override fun hasNextMediaItem() = notAllowed() override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt new file mode 100644 index 000000000..3d8099b7e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionServiceFragment.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.app.Notification +import android.content.Context +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultActionFactory +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaNotification.ActionFactory +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.guava.asListenableFuture +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.service.MediaSessionBitmapLoader +import org.oxycblt.auxio.music.service.MediaItemBrowser +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +class MediaSessionServiceFragment +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val actionHandler: PlaybackActionHandler, + private val mediaItemBrowser: MediaItemBrowser, + private val bitmapLoader: MediaSessionBitmapLoader, + exoHolderFactory: ExoPlaybackStateHolder.Factory +) : + MediaLibrarySession.Callback, + PlaybackActionHandler.Callback, + MediaItemBrowser.Invalidator, + PlaybackStateManager.Listener { + private val waitJob = Job() + private val waitScope = CoroutineScope(waitJob + Dispatchers.Default) + private val exoHolder = exoHolderFactory.create() + + private lateinit var actionFactory: ActionFactory + private val mediaNotificationProvider = + DefaultMediaNotificationProvider.Builder(context) + .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) + .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") + .setChannelName(R.string.lbl_playback) + .build() + .also { it.setSmallIcon(R.drawable.ic_auxio_24) } + private var foregroundListener: ForegroundListener? = null + + lateinit var mediaSession: MediaLibrarySession + private set + + // --- MEDIASESSION CALLBACKS --- + + fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession { + foregroundListener = listener + mediaSession = createSession(service) + service.addSession(mediaSession) + actionFactory = DefaultActionFactory(service) + playbackManager.addListener(this) + exoHolder.attach() + actionHandler.attach(this) + mediaItemBrowser.attach(this) + return mediaSession + } + + fun handleTaskRemoved() { + if (playbackManager.progression.isPlaying) { + playbackManager.endSession() + } + } + + fun release() { + waitJob.cancel() + mediaSession.release() + actionHandler.release() + exoHolder.release() + playbackManager.removeListener(this) + mediaSession.release() + foregroundListener = null + } + + fun hasNotification(): Boolean = exoHolder.sessionOngoing + + fun createNotification(post: (MediaNotification) -> Unit) { + val notification = + mediaNotificationProvider.createNotification( + mediaSession, mediaSession.customLayout, actionFactory) { notification -> + post(wrapMediaNotification(notification)) + } + post(wrapMediaNotification(notification)) + } + + private fun wrapMediaNotification(notification: MediaNotification): MediaNotification { + // Pulled from MediaNotificationManager: Need to specify MediaSession token manually + // in notification + val fwkToken = + mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token + notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) + return notification + } + + private fun createSession(service: MediaLibraryService) = + MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this) + .setBitmapLoader(bitmapLoader) + .build() + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + val sessionCommands = + actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .setCustomLayout(actionHandler.createCustomLayout()) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = + if (actionHandler.handleCommand(customCommand)) { + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } else { + super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> = + Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val result = + mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(result) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture = + Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture>> { + val children = + mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError>( + LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(children) + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> = + waitScope + .async { + val count = mediaItemBrowser.prepareSearch(query) + session.notifySearchResultChanged(browser, query, count, params) + LibraryResult.ofVoid() + } + .asListenableFuture() + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams? + ) = + waitScope + .async { + mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + .asListenableFuture() + + override fun onSessionEnded() { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + + override fun onCustomLayoutChanged(layout: List) { + mediaSession.setCustomLayout(layout) + } + + override fun invalidate(ids: List) { + for (id in ids) { + mediaSession.notifyChildrenChanged(id, Int.MAX_VALUE, null) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt index 2d73d5897..5d89acd4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt @@ -23,14 +23,141 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.AudioManager +import android.os.Bundle +import androidx.core.content.ContextCompat +import androidx.media3.session.CommandButton +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionCommands +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider +class PlaybackActionHandler +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val systemReceiver: SystemPlaybackReceiver +) : PlaybackStateManager.Listener, PlaybackSettings.Listener { + + interface Callback { + fun onCustomLayoutChanged(layout: List) + } + + private var callback: Callback? = null + + fun attach(callback: Callback) { + this.callback = callback + playbackManager.addListener(this) + playbackSettings.registerListener(this) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + } + + fun release() { + callback = null + playbackManager.removeListener(this) + playbackSettings.unregisterListener(this) + context.unregisterReceiver(systemReceiver) + } + + fun withCommands(commands: SessionCommands) = + commands + .buildUpon() + .add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) + .add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) + .add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY)) + .build() + + fun handleCommand(command: SessionCommand): Boolean { + when (command.customAction) { + PlaybackActions.ACTION_INC_REPEAT_MODE -> + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + PlaybackActions.ACTION_INVERT_SHUFFLE -> + playbackManager.shuffled(!playbackManager.isShuffled) + PlaybackActions.ACTION_EXIT -> playbackManager.endSession() + else -> return false + } + return true + } + + fun createCustomLayout(): List { + val actions = mutableListOf() + + when (playbackSettings.notificationAction) { + ActionMode.REPEAT -> { + actions.add( + CommandButton.Builder() + .setIconResId(playbackManager.repeatMode.icon) + .setDisplayName(context.getString(R.string.desc_change_repeat)) + .setSessionCommand( + SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) + .build()) + } + ActionMode.SHUFFLE -> { + actions.add( + CommandButton.Builder() + .setIconResId( + if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 + else R.drawable.ic_shuffle_off_24) + .setDisplayName(context.getString(R.string.lbl_shuffle)) + .setSessionCommand( + SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) + .build()) + } + else -> {} + } + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_close_24) + .setDisplayName(context.getString(R.string.desc_exit)) + .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) + .build()) + + return actions + } + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + super.onRepeatModeChanged(repeatMode) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + super.onQueueReordered(queue, index, isShuffled) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + + override fun onNotificationActionChanged() { + super.onNotificationActionChanged() + callback?.onCustomLayoutChanged(createCustomLayout()) + } +} + +object PlaybackActions { + const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" + const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" + const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" + const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" + const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" + const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" +} + /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. @@ -48,11 +175,11 @@ constructor( IntentFilter().apply { addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(AuxioService.ACTION_INC_REPEAT_MODE) - addAction(AuxioService.ACTION_INVERT_SHUFFLE) - addAction(AuxioService.ACTION_SKIP_PREV) - addAction(AuxioService.ACTION_PLAY_PAUSE) - addAction(AuxioService.ACTION_SKIP_NEXT) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) addAction(WidgetProvider.ACTION_WIDGET_UPDATE) } @@ -82,26 +209,30 @@ constructor( } // --- AUXIO EVENTS --- - AuxioService.ACTION_PLAY_PAUSE -> { + PlaybackActions.ACTION_PLAY_PAUSE -> { logD("Received play event") playbackManager.playing(!playbackManager.progression.isPlaying) } - AuxioService.ACTION_INC_REPEAT_MODE -> { + PlaybackActions.ACTION_INC_REPEAT_MODE -> { logD("Received repeat mode event") playbackManager.repeatMode(playbackManager.repeatMode.increment()) } - AuxioService.ACTION_INVERT_SHUFFLE -> { + PlaybackActions.ACTION_INVERT_SHUFFLE -> { logD("Received shuffle event") playbackManager.shuffled(!playbackManager.isShuffled) } - AuxioService.ACTION_SKIP_PREV -> { + PlaybackActions.ACTION_SKIP_PREV -> { logD("Received skip previous event") playbackManager.prev() } - AuxioService.ACTION_SKIP_NEXT -> { + PlaybackActions.ACTION_SKIP_NEXT -> { logD("Received skip next event") playbackManager.next() } + PlaybackActions.ACTION_EXIT -> { + logD("Received exit event") + playbackManager.endSession() + } WidgetProvider.ACTION_WIDGET_UPDATE -> { logD("Received widget update event") widgetComponent.update() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index 8780dcdbb..857ac6898 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -147,6 +147,9 @@ interface PlaybackStateHolder { */ fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) + /** End whatever ongoing playback session may be going on */ + fun endSession() + /** Reset this instance to an empty state. */ fun reset(ack: StateAck.NewPlayback) } @@ -195,6 +198,8 @@ sealed interface StateAck { /** @see PlaybackStateHolder.repeatMode */ data object RepeatModeChanged : StateAck + + data object SessionEnded : StateAck } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index d9364fe91..347b099ca 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -233,8 +233,7 @@ interface PlaybackStateManager { */ fun seekTo(positionMs: Long) - /** Rewind to the beginning of the currently playing [Song]. */ - fun rewind() = seekTo(0) + fun endSession() /** * Converts the current state of this instance into a [SavedState]. @@ -313,6 +312,8 @@ interface PlaybackStateManager { * @param repeatMode The new [RepeatMode]. */ fun onRepeatModeChanged(repeatMode: RepeatMode) {} + + fun onSessionEnded() {} } /** @@ -564,6 +565,13 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { stateHolder.seekTo(positionMs) } + @Synchronized + override fun endSession() { + val stateHolder = stateHolder ?: return + logD("Ending session") + stateHolder.endSession() + } + @Synchronized override fun ack(stateHolder: PlaybackStateHolder, ack: StateAck) { if (BuildConfig.DEBUG && this.stateHolder !== stateHolder) { @@ -690,6 +698,9 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { ) listeners.forEach { it.onRepeatModeChanged(stateMirror.repeatMode) } } + is StateAck.SessionEnded -> { + listeners.forEach { it.onSessionEnded() } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt deleted file mode 100644 index df1c1e604..000000000 --- a/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ForegroundServiceNotification.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.ui - -import android.content.Context -import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat - -/** - * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that - * signal a Service's ongoing foreground state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : - NotificationCompat.Builder(context, info.id) { - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // Set up the notification channel. Foreground notifications are non-substantial, and - // thus make no sense to have lights, vibration, or lead to a notification badge. - val channel = - NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(info.nameRes)) - .setLightsEnabled(false) - .setVibrationEnabled(false) - .setShowBadge(false) - .build() - notificationManager.createNotificationChannel(channel) - } - - /** - * The code used to identify this notification. - * - * @see NotificationManagerCompat.notify - */ - abstract val code: Int - - /** Post this notification using [NotificationManagerCompat]. */ - fun post() { - // This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground - // notification. - @Suppress("MissingPermission") notificationManager.notify(code, build()) - } - - /** - * Reduced representation of a [NotificationChannelCompat]. - * - * @param id The ID of the channel. - * @param nameRes A string resource ID corresponding to the human-readable name of this channel. - */ - data class ChannelInfo(val id: String, @StringRes val nameRes: Int) -} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 9ffd7631f..a13cd9895 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -28,10 +28,10 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.RemoteViews -import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.service.PlaybackActions import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.logD @@ -339,7 +339,7 @@ class WidgetProvider : AppWidgetProvider() { // by PlaybackService. setOnClickPendingIntent( R.id.widget_play_pause, - context.newBroadcastPendingIntent(AuxioService.ACTION_PLAY_PAUSE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_PLAY_PAUSE)) // Set up the play/pause button appearance. Like the Android 13 media controls, use // a circular FAB when paused, and a squircle FAB when playing. This does require us @@ -379,9 +379,11 @@ class WidgetProvider : AppWidgetProvider() { // Hook the skip buttons to the respective broadcasts that can be recognized // by PlaybackService. setOnClickPendingIntent( - R.id.widget_skip_prev, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_PREV)) + R.id.widget_skip_prev, + context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_PREV)) setOnClickPendingIntent( - R.id.widget_skip_next, context.newBroadcastPendingIntent(AuxioService.ACTION_SKIP_NEXT)) + R.id.widget_skip_next, + context.newBroadcastPendingIntent(PlaybackActions.ACTION_SKIP_NEXT)) return this } @@ -403,10 +405,10 @@ class WidgetProvider : AppWidgetProvider() { // be recognized by PlaybackService. setOnClickPendingIntent( R.id.widget_repeat, - context.newBroadcastPendingIntent(AuxioService.ACTION_INC_REPEAT_MODE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_INC_REPEAT_MODE)) setOnClickPendingIntent( R.id.widget_shuffle, - context.newBroadcastPendingIntent(AuxioService.ACTION_INVERT_SHUFFLE)) + context.newBroadcastPendingIntent(PlaybackActions.ACTION_INVERT_SHUFFLE)) // Set up the repeat/shuffle buttons. When working with RemoteViews, we will // need to hard-code different accent tinting configurations, as stateful drawables From 800ebfe77e5a2589984022beb7a0f387fc163324 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 Apr 2024 23:56:18 -0600 Subject: [PATCH 054/110] build: bump media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index ed6494429..bfa4c10f7 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit ed6494429db08fb8bb5dbeea8b505a755a6ad1a5 +Subproject commit bfa4c10f773bb9336d9c7dade490463318b12ab6 From 3a4ddb43b9233c27e2535b2ca6db3184b3847d62 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 Apr 2024 13:57:11 -0600 Subject: [PATCH 055/110] service: handle non-native start Restore the state by default when another app starts the service. A simple first step to ensure service independence (no clue if it's enough) --- .../java/org/oxycblt/auxio/AuxioService.kt | 27 +++++++++++++++++++ .../java/org/oxycblt/auxio/MainActivity.kt | 4 ++- .../service/MediaSessionServiceFragment.kt | 25 +++++++++++------ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 74ce64c14..8178a8218 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio import android.annotation.SuppressLint import android.content.Intent +import android.os.IBinder import androidx.core.app.ServiceCompat import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession @@ -41,6 +42,27 @@ class AuxioService : MediaLibraryService(), ForegroundListener { indexingFragment.attach(this) } + override fun onBind(intent: Intent?): IBinder? { + handleIntent(intent) + return super.onBind(intent) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // TODO: Start command occurring from a foreign service basically implies a detached + // service, we might need more handling here. + handleIntent(intent) + return super.onStartCommand(intent, flags, startId) + } + + private fun handleIntent(intent: Intent?) { + val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false + if (!nativeStart) { + // Some foreign code started us, no guarantees about foreground stability. Figure + // out what to do. + mediaSessionFragment.handleNonNativeStart() + } + } + override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) mediaSessionFragment.handleTaskRemoved() @@ -78,6 +100,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener { } } } + + companion object { + // This is only meant for Auxio to internally ensure that it's state management will work. + const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START" + } } interface ForegroundListener { diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index e727316fb..42ad2a134 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -69,7 +69,9 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - startService(Intent(this, AuxioService::class.java)) + startService( + Intent(this, AuxioService::class.java) + .putExtra(AuxioService.INTENT_KEY_NATIVE_START, true)) if (!startIntentAction(intent)) { // No intent action to do, just restore the previously saved state. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 3d8099b7e..a93aa019a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -50,7 +50,9 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.service.MediaSessionBitmapLoader import org.oxycblt.auxio.music.service.MediaItemBrowser +import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD class MediaSessionServiceFragment @Inject @@ -103,14 +105,11 @@ constructor( } } - fun release() { - waitJob.cancel() - mediaSession.release() - actionHandler.release() - exoHolder.release() - playbackManager.removeListener(this) - mediaSession.release() - foregroundListener = null + fun handleNonNativeStart() { + // At minimum we want to ensure an active playback state. + // TODO: Possibly also force to go foreground? + logD("Handling non-native start.") + playbackManager.playDeferred(DeferredPlayback.RestoreState) } fun hasNotification(): Boolean = exoHolder.sessionOngoing @@ -124,6 +123,16 @@ constructor( post(wrapMediaNotification(notification)) } + fun release() { + waitJob.cancel() + mediaSession.release() + actionHandler.release() + exoHolder.release() + playbackManager.removeListener(this) + mediaSession.release() + foregroundListener = null + } + private fun wrapMediaNotification(notification: MediaNotification): MediaNotification { // Pulled from MediaNotificationManager: Need to specify MediaSession token manually // in notification From 07b17caf8ffed65439c437967fb474c5d8f3e4b5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 Apr 2024 13:58:53 -0600 Subject: [PATCH 056/110] music: fix mediaitem library update logic --- .../auxio/music/service/MediaItemBrowser.kt | 20 +++++++++++-------- .../music/service/MediaItemTranslation.kt | 2 ++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 93f383917..69ac27d7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -69,21 +69,25 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary var invalidateSearch = false + val invalidate = mutableListOf() if (changes.deviceLibrary && deviceLibrary != null) { - val ids = - MediaSessionUID.Category.IMPORTANT + - deviceLibrary.albums.map { MediaSessionUID.Single(it.uid) } + - deviceLibrary.artists.map { MediaSessionUID.Single(it.uid) } + - deviceLibrary.genres.map { MediaSessionUID.Single(it.uid) } - invalidator?.invalidate(ids.map { it.toString() }) + invalidate.addAll(MediaSessionUID.Category.DEVICE_MUSIC) + deviceLibrary.albums.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } + deviceLibrary.artists.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } + deviceLibrary.genres.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } invalidateSearch = true } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - val ids = userLibrary.playlists.map { MediaSessionUID.Single(it.uid) } - invalidator?.invalidate(ids.map { it.toString() }) + invalidate.addAll(MediaSessionUID.Category.USER_MUSIC) + userLibrary.playlists.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } invalidateSearch = true } + + if (invalidate.isNotEmpty()) { + invalidator?.invalidate(invalidate.map { it.toString() }) + } + if (invalidateSearch) { for (entry in searchResults.entries) { entry.value.cancel() diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 2a6175627..9cadbeda5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -217,6 +217,8 @@ sealed interface MediaSessionUID { override fun toString() = "$ID_CATEGORY:$id" companion object { + val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) + val USER_MUSIC = listOf(ROOT, PLAYLISTS) val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) } } From 7e07c11d3a32adbe523c9e16d7d9de6db1bfb5c7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 Apr 2024 14:01:24 -0600 Subject: [PATCH 057/110] build: update deps --- app/build.gradle | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index d7341762f..e43141a3c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -155,7 +155,7 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.7" - testImplementation "org.robolectric:robolectric:4.10.3" + testImplementation "org.robolectric:robolectric:4.11" testImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/build.gradle b/build.gradle index cee2d5125..2b2c7a9c5 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version '8.3.1' apply false + id "com.android.application" version '8.3.2' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.23-1.0.20' apply false From aac39b771d494d0e9582496dc602d3287b3f9266 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 Apr 2024 14:04:28 -0600 Subject: [PATCH 058/110] music: sort mediaitems sent in browser --- .../auxio/music/service/MediaItemBrowser.kt | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 69ac27d7f..a9c42527b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -44,6 +46,7 @@ class MediaItemBrowser constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, + private val listSettings: ListSettings, private val searchEngine: SearchEngine ) : MusicRepository.UpdateListener { private val browserJob = Job() @@ -142,13 +145,21 @@ constructor( MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } MediaSessionUID.Category.SONGS -> - deviceLibrary.songs.map { it.toMediaItem(context, null) } + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } MediaSessionUID.Category.ALBUMS -> - deviceLibrary.albums.map { it.toMediaItem(context) } + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } MediaSessionUID.Category.ARTISTS -> - deviceLibrary.artists.map { it.toMediaItem(context) } + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } MediaSessionUID.Category.GENRES -> - deviceLibrary.genres.map { it.toMediaItem(context) } + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } @@ -168,14 +179,18 @@ constructor( private fun getChildMediaItems(uid: Music.UID): List? { return when (val item = musicRepository.find(uid)) { is Album -> { - item.songs.map { it.toMediaItem(context, item) } + val songs = listSettings.albumSongSort.songs(item.songs) + songs.map { it.toMediaItem(context, item) } } is Artist -> { - (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context) } + - item.songs.map { it.toMediaItem(context, item) } + val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) + val songs = listSettings.artistSongSort.songs(item.songs) + albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } } is Genre -> { - item.songs.map { it.toMediaItem(context, item) } + val artists = GENRE_ARTISTS_SORT.artists(item.artists) + val songs = listSettings.genreSongSort.songs(item.songs) + artists.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, null) } } is Playlist -> { item.songs.map { it.toMediaItem(context, item) } @@ -290,4 +305,10 @@ constructor( } return subList(start, end).toMutableList() } + + private companion object { + // TODO: Rely on detail item gen logic? + val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } } From 583e984c70b7edfd3551ccbf41a67127c7f3f242 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 Apr 2024 14:04:51 -0600 Subject: [PATCH 059/110] playback: hide exoholder save impl --- .../oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index f7dada9eb..2bed6c3a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -486,7 +486,7 @@ class ExoPlaybackStateHolder( player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat } - fun save(cb: () -> Unit) { + private fun save(cb: () -> Unit) { saveJob { persistenceRepository.saveState(playbackManager.toSavedState()) withContext(Dispatchers.Main) { cb() } From 02b7acd1c5ce9b76cebda49893539c4d30b13789 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 Apr 2024 12:15:16 -0600 Subject: [PATCH 060/110] music: update search results as well --- .../auxio/music/service/MediaItemBrowser.kt | 72 ++++++++++--------- .../service/MediaSessionServiceFragment.kt | 12 +++- .../org/oxycblt/auxio/search/SearchEngine.kt | 10 +-- .../java/org/oxycblt/auxio/tasker/Tasker.kt | 2 + 4 files changed, 56 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index a9c42527b..1b0252e10 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.service import android.content.Context import androidx.media3.common.MediaItem +import androidx.media3.session.MediaSession.ControllerInfo import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlin.math.min @@ -51,11 +52,14 @@ constructor( ) : MusicRepository.UpdateListener { private val browserJob = Job() private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchSubscribers = mutableMapOf() private val searchResults = mutableMapOf>() private var invalidator: Invalidator? = null interface Invalidator { fun invalidate(ids: List) + + fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) } fun attach(invalidator: Invalidator) { @@ -93,9 +97,16 @@ constructor( if (invalidateSearch) { for (entry in searchResults.entries) { - entry.value.cancel() + searchResults[entry.key]?.cancel() } searchResults.clear() + + for (entry in searchSubscribers.entries) { + if (searchResults[entry.value] != null) { + continue + } + searchResults[entry.value] = searchTo(entry.value) + } } } @@ -190,7 +201,8 @@ constructor( is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, null) } + artists.map { it.toMediaItem(context) } + + songs.map { it.toMediaItem(context, null) } } is Playlist -> { item.songs.map { it.toMediaItem(context, item) } @@ -200,20 +212,17 @@ constructor( } } - suspend fun prepareSearch(query: String): Int { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return 0 + suspend fun prepareSearch(query: String, controller: ControllerInfo) { + searchSubscribers[controller] = query + val existing = searchResults[query] + if (existing == null) { + val new = searchTo(query) + searchResults[query] = new + new.await() + } else { + val items = existing.await() + invalidator?.invalidate(controller, query, items.count()) } - - if (query.isEmpty()) { - return 0 - } - - val deferred = searchTo(query, deviceLibrary, userLibrary) - searchResults[query] = deferred - return deferred.await().count() } suspend fun getSearchResult( @@ -221,22 +230,8 @@ constructor( page: Int, pageSize: Int, ): List? { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return listOf() - } - - if (query.isEmpty()) { - return listOf() - } - - val existing = searchResults[query] - if (existing != null) { - return existing.await().concat().paginate(page, pageSize) - } - - return searchTo(query, deviceLibrary, userLibrary).await().concat().paginate(page, pageSize) + val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } + return deferred.await().concat().paginate(page, pageSize) } private fun SearchEngine.Items.concat(): MutableList { @@ -279,8 +274,13 @@ constructor( return count } - private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + private fun searchTo(query: String) = searchScope.async { + if (query.isEmpty()) { + return@async SearchEngine.Items() + } + val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() + val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() val items = SearchEngine.Items( deviceLibrary.songs, @@ -288,7 +288,13 @@ constructor( deviceLibrary.artists, deviceLibrary.genres, userLibrary.playlists) - searchEngine.search(items, query) + val results = searchEngine.search(items, query) + for (entry in searchSubscribers.entries) { + if (entry.value == query) { + invalidator?.invalidate(entry.key, query, results.count()) + } + } + results } private fun List.paginate(page: Int, pageSize: Int): List? { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index a93aa019a..91635d7dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -224,8 +224,8 @@ constructor( ): ListenableFuture> = waitScope .async { - val count = mediaItemBrowser.prepareSearch(query) - session.notifySearchResultChanged(browser, query, count, params) + mediaItemBrowser.prepareSearch(query, browser) + // Invalidator will send the notify result LibraryResult.ofVoid() } .asListenableFuture() @@ -260,4 +260,12 @@ constructor( mediaSession.notifyChildrenChanged(id, Int.MAX_VALUE, null) } } + + override fun invalidate( + controller: MediaSession.ControllerInfo, + query: String, + itemCount: Int + ) { + mediaSession.notifySearchResultChanged(controller, query, itemCount, null) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 0e0944961..7853bcca3 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -56,11 +56,11 @@ interface SearchEngine { * @param playlists A list of [Playlist], null if empty. */ data class Items( - val songs: Collection?, - val albums: Collection?, - val artists: Collection?, - val genres: Collection?, - val playlists: Collection? + val songs: Collection? = null, + val albums: Collection? = null, + val artists: Collection? = null, + val genres: Collection? = null, + val playlists: Collection? = null ) } diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt new file mode 100644 index 000000000..e823bb338 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt @@ -0,0 +1,2 @@ +package org.oxycblt.auxio.tasker + From c8571a4df30f29a22b367622ef1d7eed98b6e866 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 Apr 2024 12:16:38 -0600 Subject: [PATCH 061/110] playback: fix broken play actions --- .../main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt | 2 ++ .../java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 4326e993a..3cccaa0f4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -267,6 +267,7 @@ constructor( val params = commandFactory.songFromArtist(song, artist, shuffle) if (params != null) { playbackManager.play(params) + return } logD( "Cannot use given artist parameter for $song [$artist from ${song.artists}], showing choice dialog") @@ -277,6 +278,7 @@ constructor( val params = commandFactory.songFromGenre(song, genre, shuffle) if (params != null) { playbackManager.play(params) + return } logD( "Cannot use given genre parameter for $song [$genre from ${song.genres}], showing choice dialog") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt index 90e435a26..82685f051 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt @@ -162,7 +162,7 @@ constructor( sort: Sort, shuffle: ShuffleMode ): PlaybackCommand? { - if (queue.isEmpty() || song !in queue) { + if (queue.isEmpty() || (song != null && song !in queue)) { return null } return newCommand(song, parent, sort.songs(queue), shuffle) From a3e74cbd1efaa92e5059971269baada3783ffbc7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 Apr 2024 12:59:31 -0600 Subject: [PATCH 062/110] music: update search results when library changes --- .../auxio/music/service/MediaItemBrowser.kt | 77 ++++++++++++++++--- .../service/MediaSessionServiceFragment.kt | 4 +- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 1b0252e10..5c8c35ede 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context @@ -57,7 +57,9 @@ constructor( private var invalidator: Invalidator? = null interface Invalidator { - fun invalidate(ids: List) + data class ParentId(val id: String, val itemCount: Int) + + fun invalidate(ids: Map) fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) } @@ -76,23 +78,43 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary var invalidateSearch = false - val invalidate = mutableListOf() + val invalidate = mutableMapOf() if (changes.deviceLibrary && deviceLibrary != null) { - invalidate.addAll(MediaSessionUID.Category.DEVICE_MUSIC) - deviceLibrary.albums.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } - deviceLibrary.artists.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } - deviceLibrary.genres.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } + MediaSessionUID.Category.DEVICE_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + + deviceLibrary.albums.forEach { + val id = MediaSessionUID.Single(it.uid).toString() + invalidate[id] = it.songs.size + } + + deviceLibrary.artists.forEach { + val id = MediaSessionUID.Single(it.uid).toString() + invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + } + + deviceLibrary.genres.forEach { + val id = MediaSessionUID.Single(it.uid).toString() + invalidate[id] = it.songs.size + it.artists.size + } + invalidateSearch = true } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - invalidate.addAll(MediaSessionUID.Category.USER_MUSIC) - userLibrary.playlists.mapTo(invalidate) { MediaSessionUID.Single(it.uid) } + MediaSessionUID.Category.USER_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + userLibrary.playlists.forEach { + val id = MediaSessionUID.Single(it.uid).toString() + invalidate[id] = it.songs.size + } invalidateSearch = true } if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate.map { it.toString() }) + invalidator?.invalidate(invalidate) } if (invalidateSearch) { @@ -119,8 +141,10 @@ constructor( is MediaSessionUID.Category -> return uid.toMediaItem(context) is MediaSessionUID.Single -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + is MediaSessionUID.Joined -> musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + null -> null } ?: return null @@ -155,32 +179,40 @@ constructor( when (mediaSessionUID) { MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + MediaSessionUID.Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } + MediaSessionUID.Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } + MediaSessionUID.Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } + MediaSessionUID.Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } + MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } + is MediaSessionUID.Single -> { getChildMediaItems(mediaSessionUID.uid) } + is MediaSessionUID.Joined -> { getChildMediaItems(mediaSessionUID.childUid) } + null -> { return null } @@ -193,25 +225,45 @@ constructor( val songs = listSettings.albumSongSort.songs(item.songs) songs.map { it.toMediaItem(context, item) } } + is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } } + is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) artists.map { it.toMediaItem(context) } + - songs.map { it.toMediaItem(context, null) } + songs.map { it.toMediaItem(context, null) } } + is Playlist -> { item.songs.map { it.toMediaItem(context, item) } } + is Song, null -> return null } } + private fun getCategorySize( + category: MediaSessionUID.Category, + musicRepository: MusicRepository + ): Int { + val deviceLibrary = musicRepository.deviceLibrary ?: return 0 + val userLibrary = musicRepository.userLibrary ?: return 0 + return when (category) { + MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size + MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size + MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size + MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size + MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size + MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size + } + } + suspend fun prepareSearch(query: String, controller: ControllerInfo) { searchSubscribers[controller] = query val existing = searchResults[query] @@ -287,7 +339,8 @@ constructor( deviceLibrary.albums, deviceLibrary.artists, deviceLibrary.genres, - userLibrary.playlists) + userLibrary.playlists + ) val results = searchEngine.search(items, query) for (entry in searchSubscribers.entries) { if (entry.value == query) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 91635d7dc..65caa80af 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -255,9 +255,9 @@ constructor( mediaSession.setCustomLayout(layout) } - override fun invalidate(ids: List) { + override fun invalidate(ids: Map){ for (id in ids) { - mediaSession.notifyChildrenChanged(id, Int.MAX_VALUE, null) + mediaSession.notifyChildrenChanged(id.key, id.value, null) } } From 25eaf899984ad4bcbd8eff1e56ac72d96cf45b48 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 Apr 2024 13:25:32 -0600 Subject: [PATCH 063/110] Update bug-crash-report.yml --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index cf09cc099..904f5499e 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -61,7 +61,7 @@ body: id: sample-file attributes: label: Provide a sample file - description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. + description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. **UPLOAD AS EITHER A .ZIP FILE CONTAINING THE SONGS OR AS A SHARED LINK TO A FILE HOSTED ON THE CLOUD.** validations: required: true - type: textarea From 624924066045cef82506ede908eece460ce50943 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 Apr 2024 13:26:28 -0600 Subject: [PATCH 064/110] Update bug-crash-report.yml --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 904f5499e..3680b609d 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -61,8 +61,7 @@ body: id: sample-file attributes: label: Provide a sample file - description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. **UPLOAD AS EITHER A .ZIP FILE CONTAINING THE SONGS OR AS A SHARED LINK TO A FILE HOSTED ON THE CLOUD.** - validations: + description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.* required: true - type: textarea id: logs From 957e212e59775eb0b4358f8b65bc033de431df9e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 Apr 2024 19:47:39 -0600 Subject: [PATCH 065/110] Fix bug report template --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 3680b609d..abc516401 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -62,6 +62,7 @@ body: attributes: label: Provide a sample file description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.* + validations: required: true - type: textarea id: logs From f04e05ad50fc41f157febaf58a8e29cdff0f27e9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 Apr 2024 19:40:34 -0600 Subject: [PATCH 066/110] playback: immediately ack index moves Handling them later in the callback is no longer needed now that we have the MediaSession shim, and it caused desyncs in ReplayGain support. --- .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../service/ExoPlaybackStateHolder.kt | 45 +++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 09168842d..1152ef4e3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -57,7 +57,7 @@ constructor( flush() } - init { + fun attach() { playbackManager.addListener(this) playbackSettings.registerListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 2bed6c3a1..3666f1795 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -69,7 +69,8 @@ class ExoPlaybackStateHolder( private val persistenceRepository: PersistenceRepository, private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository + private val musicRepository: MusicRepository, + private val replayGainProcessor: ReplayGainAudioProcessor ) : PlaybackStateHolder, Player.Listener, @@ -86,6 +87,7 @@ class ExoPlaybackStateHolder( fun attach() { player.addListener(this) + replayGainProcessor.attach() playbackManager.registerStateHolder(this) playbackSettings.registerListener(this) musicRepository.addUpdateListener(this) @@ -96,6 +98,7 @@ class ExoPlaybackStateHolder( player.removeListener(this) playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) + replayGainProcessor.release() player.release() } @@ -190,7 +193,8 @@ class ExoPlaybackStateHolder( override fun seekTo(positionMs: Long) { player.seekTo(positionMs) - // Ack/state save handled on discontinuity + deferSave() + // Ack handled w/ExoPlayer events } override fun repeatMode(repeatMode: RepeatMode) { @@ -253,7 +257,8 @@ class ExoPlaybackStateHolder( player.pause() } } - // Ack/state save is handled in timeline change + playbackManager.ack(this, StateAck.IndexMoved) + deferSave() } override fun prev() { @@ -265,7 +270,8 @@ class ExoPlaybackStateHolder( if (!playbackSettings.rememberPause) { player.play() } - // Ack/state save is handled in timeline change + playbackManager.ack(this, StateAck.IndexMoved) + deferSave() } override fun goto(index: Int) { @@ -279,7 +285,8 @@ class ExoPlaybackStateHolder( if (!playbackSettings.rememberPause) { player.play() } - // Ack/state save is handled in timeline change + playbackManager.ack(this, StateAck.IndexMoved) + deferSave() } override fun playNext(songs: List, ack: StateAck.PlayNext) { @@ -405,15 +412,6 @@ class ExoPlaybackStateHolder( } } - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - super.onMediaItemTransition(mediaItem, reason) - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || - reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { - playbackManager.ack(this, StateAck.IndexMoved) - } - } - override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) @@ -423,21 +421,19 @@ class ExoPlaybackStateHolder( } } - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - if (reason == Player.DISCONTINUITY_REASON_SEEK) { - // TODO: Once position also naturally drifts by some threshold, save - deferSave() + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { + playbackManager.ack(this, StateAck.IndexMoved) } } override fun onEvents(player: Player, events: Player.Events) { super.onEvents(player, events) + // So many actions trigger progression changes that it becomes easier just to handle it + // in an ExoPlayer callback anyway. This doesn't really cause issues anywhere. if (events.containsAny( Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_IS_PLAYING_CHANGED, @@ -559,7 +555,8 @@ class ExoPlaybackStateHolder( persistenceRepository, playbackSettings, commandFactory, - musicRepository) + musicRepository, + replayGainProcessor) } } From b99cd967262f1e4180f84490db9f8a2d1d75bfa0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 16:06:18 -0600 Subject: [PATCH 067/110] playback: fix task removal --- .../auxio/playback/service/MediaSessionServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 65caa80af..4d87ae4ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -100,7 +100,7 @@ constructor( } fun handleTaskRemoved() { - if (playbackManager.progression.isPlaying) { + if (!playbackManager.progression.isPlaying) { playbackManager.endSession() } } From 8b7b916489cd6d842baeb4b60ed5b98cca29af83 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 18:48:54 -0600 Subject: [PATCH 068/110] playback: fix notif issues on older devices - Slight coroutine delay in cover fetch causes the notif to flicker - Default play/pause actions look absolutely hideous --- .../auxio/image/service/CoilBitmapLoader.kt | 18 +++++- .../auxio/music/service/MediaItemBrowser.kt | 21 +------ .../service/ExoPlaybackStateHolder.kt | 3 +- .../playback/service/MediaSessionPlayer.kt | 18 ++++++ .../service/MediaSessionServiceFragment.kt | 2 +- ...ckReciever.kt => PlaybackActionHandler.kt} | 57 ++++++++++++++++++- .../java/org/oxycblt/auxio/tasker/Tasker.kt | 19 ++++++- media | 2 +- 8 files changed, 116 insertions(+), 24 deletions(-) rename app/src/main/java/org/oxycblt/auxio/playback/service/{SystemPlaybackReciever.kt => PlaybackActionHandler.kt} (81%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt index 58e8b609d..ca94f66cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -18,22 +18,31 @@ package org.oxycblt.auxio.image.service +import android.content.Context import android.graphics.Bitmap import android.net.Uri import androidx.media3.common.MediaMetadata import androidx.media3.common.util.BitmapLoader +import coil.ImageLoader +import coil.memory.MemoryCache +import coil.request.Options import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.extractor.SongKeyer import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID class MediaSessionBitmapLoader @Inject constructor( + @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, - private val bitmapProvider: BitmapProvider + private val bitmapProvider: BitmapProvider, + private val songKeyer: SongKeyer, + private val imageLoader: ImageLoader, ) : BitmapLoader { override fun decodeBitmap(data: ByteArray): ListenableFuture { throw NotImplementedError() @@ -58,6 +67,13 @@ constructor( else -> return null } ?: return null + // Even launching a coroutine to obtained cached covers is enough to make the notification + // go without covers. + val key = songKeyer.key(listOf(song), Options(context)) + if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { + future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) + return future + } bitmapProvider.load( song, object : BitmapProvider.Target { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 5c8c35ede..63b68925d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context @@ -141,10 +141,8 @@ constructor( is MediaSessionUID.Category -> return uid.toMediaItem(context) is MediaSessionUID.Single -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.Joined -> musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null } ?: return null @@ -179,40 +177,32 @@ constructor( when (mediaSessionUID) { MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - MediaSessionUID.Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - MediaSessionUID.Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - MediaSessionUID.Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } - is MediaSessionUID.Single -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.Joined -> { getChildMediaItems(mediaSessionUID.childUid) } - null -> { return null } @@ -225,24 +215,20 @@ constructor( val songs = listSettings.albumSongSort.songs(item.songs) songs.map { it.toMediaItem(context, item) } } - is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } } - is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) artists.map { it.toMediaItem(context) } + - songs.map { it.toMediaItem(context, null) } + songs.map { it.toMediaItem(context, null) } } - is Playlist -> { item.songs.map { it.toMediaItem(context, item) } } - is Song, null -> return null } @@ -339,8 +325,7 @@ constructor( deviceLibrary.albums, deviceLibrary.artists, deviceLibrary.genres, - userLibrary.playlists - ) + userLibrary.playlists) val results = searchEngine.search(items, query) for (entry in searchSubscribers.entries) { if (entry.value == query) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 3666f1795..070432bc8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -106,7 +106,8 @@ class ExoPlaybackStateHolder( private set val mediaSessionPlayer: Player - get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository) + get() = + MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) override val progression: Progression get() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index fb662ef2f..6b56d334a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.playback.service +import android.content.Context +import android.os.Bundle import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView @@ -31,6 +33,7 @@ import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters import java.lang.Exception +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -60,6 +63,7 @@ import org.oxycblt.auxio.util.logE * @author Alexander Capehart */ class MediaSessionPlayer( + private val context: Context, player: Player, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, @@ -86,6 +90,20 @@ class MediaSessionPlayer( setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) } + override fun getMediaMetadata() = + super.getMediaMetadata().run { + val existingExtras = extras + val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() + newExtras.apply { + putString( + "parent", + playbackManager.parent?.name?.resolve(context) + ?: context.getString(R.string.lbl_all_songs)) + } + + buildUpon().setExtras(newExtras).build() + } + override fun setMediaItems( mediaItems: MutableList, startIndex: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 4d87ae4ce..a626cd4b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -255,7 +255,7 @@ constructor( mediaSession.setCustomLayout(layout) } - override fun invalidate(ids: Map){ + override fun invalidate(ids: Map) { for (id in ids) { mediaSession.notifyChildrenChanged(id.key, id.value, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 5d89acd4c..7aa37e36b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * SystemPlaybackReciever.kt is part of Auxio. + * PlaybackActionHandler.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,7 +25,9 @@ import android.content.IntentFilter import android.media.AudioManager import android.os.Bundle import androidx.core.content.ContextCompat +import androidx.media3.common.Player import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommands import dagger.hilt.android.qualifiers.ApplicationContext @@ -36,6 +38,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent @@ -102,6 +105,13 @@ constructor( .setDisplayName(context.getString(R.string.desc_change_repeat)) .setSessionCommand( SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt( + DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, + 0) + }) .build()) } ActionMode.SHUFFLE -> { @@ -113,16 +123,56 @@ constructor( .setDisplayName(context.getString(R.string.lbl_shuffle)) .setSessionCommand( SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) + .setEnabled(true) .build()) } else -> {} } + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_skip_prev_24) + .setDisplayName(context.getString(R.string.desc_skip_prev)) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) + }) + .build()) + + actions.add( + CommandButton.Builder() + .setIconResId( + if (playbackManager.progression.isPlaying) R.drawable.ic_pause_24 + else R.drawable.ic_play_24) + .setDisplayName(context.getString(R.string.desc_play_pause)) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) + }) + .build()) + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_skip_next_24) + .setDisplayName(context.getString(R.string.desc_skip_next)) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 3) + }) + .build()) + actions.add( CommandButton.Builder() .setIconResId(R.drawable.ic_close_24) .setDisplayName(context.getString(R.string.desc_exit)) .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) + .setEnabled(true) .build()) return actions @@ -133,6 +183,11 @@ constructor( callback?.onCustomLayoutChanged(createCustomLayout()) } + override fun onProgressionChanged(progression: Progression) { + super.onProgressionChanged(progression) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + override fun onRepeatModeChanged(repeatMode: RepeatMode) { super.onRepeatModeChanged(repeatMode) callback?.onCustomLayoutChanged(createCustomLayout()) diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt index e823bb338..ec2d6ac99 100644 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt @@ -1,2 +1,19 @@ +/* + * Copyright (c) 2024 Auxio Project + * Tasker.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.tasker - diff --git a/media b/media index bfa4c10f7..6c77cfa13 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit bfa4c10f773bb9336d9c7dade490463318b12ab6 +Subproject commit 6c77cfa13c83bf2ae5188603d2c9a51ec4cb3ac3 From bd330f0c713da03fa280f34a43af7a11c75a6c4b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 22:16:22 -0600 Subject: [PATCH 069/110] image: basic per-song album covers Without any good caching support, so this will immediately break down. --- .../image/extractor/{CoverUri.kt => Cover.kt} | 6 +-- .../auxio/image/extractor/CoverExtractor.kt | 50 ++++++++----------- .../java/org/oxycblt/auxio/music/Music.kt | 17 ++++--- .../auxio/music/device/DeviceMusicImpl.kt | 13 ++++- .../org/oxycblt/auxio/music/fs/StorageUtil.kt | 7 ++- .../music/service/MediaItemTranslation.kt | 10 ++-- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 2 + 7 files changed, 58 insertions(+), 47 deletions(-) rename app/src/main/java/org/oxycblt/auxio/image/extractor/{CoverUri.kt => Cover.kt} (84%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 5e32d09ff..3be13c02f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * CoverUri.kt is part of Auxio. + * Cover.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,9 +24,9 @@ import android.net.Uri * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading * images. * - * @param mediaStore The album cover [Uri] obtained from MediaStore. + * @param mediaStoreUri The album cover [Uri] obtained from MediaStore. * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain * an album cover. * @author Alexander Capehart (OxygenCobalt) */ -data class CoverUri(val mediaStore: Uri, val song: Uri) +data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 899867eb0..bb8eef06a 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -77,10 +77,10 @@ constructor( * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. */ suspend fun extract(songs: Collection, size: Size): FetchResult? { - val albums = computeCoverOrdering(songs) + val covers = computeCoverOrdering(songs) val streams = mutableListOf() - for (album in albums) { - openCoverInputStream(album)?.let(streams::add) + for (cover in covers) { + openCoverInputStream(cover)?.let(streams::add) // We don't immediately check for mosaic feasibility from album count alone, as that // does not factor in InputStreams failing to load. Instead, only check once we // definitely have image data to use. @@ -116,40 +116,33 @@ constructor( * by their names. "Representation" is defined by how many [Song]s were found to be linked to * the given [Album] in the given [Song] list. */ - fun computeCoverOrdering(songs: Collection): List { - // TODO: Start short-circuiting in more places - if (songs.isEmpty()) return listOf() - if (songs.size == 1) return listOf(songs.first().album) + fun computeCoverOrdering(songs: Collection) = + Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) + .songs(songs) + .distinctBy { (it.cover.perceptualHash ?: it.uri).toString() } + .map { it.cover } - val sortedMap = - sortedMapOf(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING)) - for (song in songs) { - sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1 - } - return sortedMap.keys.sortedByDescending { sortedMap[it] } - } - - private suspend fun openCoverInputStream(album: Album) = + private suspend fun openCoverInputStream(cover: Cover) = try { when (imageSettings.coverMode) { CoverMode.OFF -> null - CoverMode.MEDIA_STORE -> extractMediaStoreCover(album) - CoverMode.QUALITY -> extractQualityCover(album) + CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover) + CoverMode.QUALITY -> extractQualityCover(cover) } } catch (e: Exception) { logE("Unable to extract album cover due to an error: $e") null } - private suspend fun extractQualityCover(album: Album) = - extractAospMetadataCover(album) - ?: extractExoplayerCover(album) ?: extractMediaStoreCover(album) + private suspend fun extractQualityCover(cover: Cover) = + extractAospMetadataCover(cover) + ?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover) - private fun extractAospMetadataCover(album: Album): InputStream? = + private fun extractAospMetadataCover(cover: Cover): InputStream? = MediaMetadataRetriever().run { // This call is time-consuming but it also doesn't seem to hold up the main thread, // so it's probably fine not to wrap it.rmt - setDataSource(context, album.coverUri.song) + setDataSource(context, cover.songUri) // Get the embedded picture from MediaMetadataRetriever, which will return a full // ByteArray of the cover without any compression artifacts. @@ -157,10 +150,9 @@ constructor( embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } } - private suspend fun extractExoplayerCover(album: Album): InputStream? { + private suspend fun extractExoplayerCover(cover: Cover): InputStream? { val tracks = - MetadataRetriever.retrieveMetadata( - mediaSourceFactory, MediaItem.fromUri(album.coverUri.song)) + MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri)) .asDeferred() .await() @@ -204,11 +196,9 @@ constructor( return stream } - private suspend fun extractMediaStoreCover(album: Album) = + private suspend fun extractMediaStoreCover(cover: Cover) = // Eliminate any chance that this blocking call might mess up the loading process - withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(album.coverUri.mediaStore) - } + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreUri) } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ private suspend fun createMosaic(streams: List, size: Size): FetchResult { diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 1bf23aaf6..f3bf1bd06 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -27,7 +27,7 @@ import java.util.UUID import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import org.oxycblt.auxio.image.extractor.CoverUri +import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path @@ -246,6 +246,8 @@ interface Song : Music { * audio file in a way that is scoped-storage-safe. */ val uri: Uri + /** Useful information to quickly obtain the album cover. */ + val cover: Cover /** * The [Path] to this audio file. This is only intended for display, [uri] should be favored * instead for accessing the audio file. @@ -293,11 +295,8 @@ interface Album : MusicParent { * [ReleaseType.Album]. */ val releaseType: ReleaseType - /** - * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the - * cost of image quality. - */ - val coverUri: CoverUri + /** Cover information from the template song used for the album. */ + val cover: Cover /** The duration of all songs in the album, in milliseconds. */ val durationMs: Long /** The earliest date a song in this album was added, as a unix epoch timestamp. */ @@ -326,6 +325,8 @@ interface Artist : MusicParent { * songs. */ val durationMs: Long? + /** Useful information to quickly obtain a (single) cover for a Genre. */ + val cover: Cover /** The [Genre]s of this artist. */ val genres: List } @@ -340,6 +341,8 @@ interface Genre : MusicParent { val artists: Collection /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long + /** Useful information to quickly obtain a (single) cover for a Genre. */ + val cover: Cover } /** @@ -352,6 +355,8 @@ interface Playlist : MusicParent { override val songs: List /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long + /** Useful information to quickly obtain a (single) cover for a Genre. */ + val cover: Cover? } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 7b16070cc..53453eb98 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.extractor.CoverUri +import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -112,6 +112,8 @@ class SongImpl( override val genres: List get() = _genres + override val cover = Cover("", requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) + /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * [Album]. @@ -291,9 +293,9 @@ class AlbumImpl( override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName) override val dates: Date.Range? override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) - override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri) override val durationMs: Long override val dateAdded: Long + override val cover = grouping.raw.src.cover private val _artists = mutableListOf() override val artists: List @@ -419,6 +421,12 @@ class ArtistImpl( override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? + override val cover = + when (val src = grouping.raw.src) { + is AlbumImpl -> src.cover + is SongImpl -> src.cover + else -> error("Unexpected input music $src in $name ${src::class.simpleName}") + } override lateinit var genres: List @@ -528,6 +536,7 @@ class GenreImpl( override val songs: Set override val artists: Set override val durationMs: Long + override val cover = grouping.raw.src.cover private var hashCode = uid.hashCode() diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt index 87eff7081..da61d613b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt @@ -102,7 +102,12 @@ fun Long.toAudioUri() = * @return An external storage image [Uri]. May not exist. * @see ContentUris.withAppendedId */ -fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this) +fun Long.toCoverUri(): Uri = + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run { + appendPath(this@toCoverUri.toString()) + appendPath("albumart") + build() + } // --- STORAGEMANAGER UTILITIES --- // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9cadbeda5..3e05e4118 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) - .setArtworkUri(album.coverUri.mediaStore) + .setArtworkUri(album.cover.mediaStoreUri) .setExtras( Bundle().apply { putString("uid", mediaSessionUID.toString()) @@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(coverUri.mediaStore) + .setArtworkUri(cover.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { .setIsPlayable(true) .setIsBrowsable(true) .setGenre(genres.resolveNames(context)) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setArtworkUri(cover.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setArtworkUri(cover.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setArtworkUri(cover?.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index fe4418894..6d53bb41b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -46,6 +46,8 @@ private constructor( override fun toString() = "Playlist(uid=$uid, name=$name)" + override val cover = songs.firstOrNull()?.cover + /** * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. * From 51406deaa7783c272d7d7c8978b04c55755a5104 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 Apr 2024 14:30:10 -0600 Subject: [PATCH 070/110] image: complete per-song album covers - Implement perceptual hashing algorithm to efficiently cache images - Efficiently pre-sort cover sources to make cover images load without freezing and look more pleasing Resolbes #342. --- .../org/oxycblt/auxio/image/BitmapProvider.kt | 2 +- .../java/org/oxycblt/auxio/image/CoverView.kt | 37 ++++----- .../auxio/image/extractor/Components.kt | 20 ++--- .../oxycblt/auxio/image/extractor/Cover.kt | 24 +++++- .../auxio/image/extractor/CoverExtractor.kt | 82 ++++++++----------- .../oxycblt/auxio/image/extractor/DHash.kt | 59 +++++++++++++ .../auxio/image/extractor/ExtractorModule.kt | 8 +- ...pLoader.kt => MediaSessionBitmapLoader.kt} | 8 +- .../java/org/oxycblt/auxio/music/Music.kt | 9 +- .../auxio/music/cache/CacheDatabase.kt | 7 +- .../auxio/music/device/DeviceMusicImpl.kt | 27 ++++-- .../oxycblt/auxio/music/device/RawMusic.kt | 2 + .../oxycblt/auxio/music/metadata/TagWorker.kt | 19 ++++- .../music/service/MediaItemTranslation.kt | 10 +-- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 3 +- 15 files changed, 205 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt rename app/src/main/java/org/oxycblt/auxio/image/service/{CoilBitmapLoader.kt => MediaSessionBitmapLoader.kt} (94%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index ad81c25a9..59dcb877d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -94,7 +94,7 @@ constructor( target .onConfigRequest( ImageRequest.Builder(context) - .data(listOf(song)) + .data(listOf(song.cover)) // Use ORIGINAL sizing, as we are not loading into any View-like component. .size(Size.ORIGINAL)) .target( diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 792755dc7..4d0057c40 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.music.Album @@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() - private data class Cover( - val songs: Collection, - val desc: String, - @DrawableRes val errorRes: Int - ) - - private var currentCover: Cover? = null - init { // Obtain some StyledImageView attributes to use later when theming the custom view. @SuppressLint("CustomViewStyleable") @@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param song The [Song] to bind to the view. */ fun bind(song: Song) = - bind( - listOf(song), + bindImpl( + listOf(song.cover), context.getString(R.string.desc_album_cover, song.album.name), R.drawable.ic_album_24) @@ -353,8 +346,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param album The [Album] to bind to the view. */ fun bind(album: Album) = - bind( - album.songs, + bindImpl( + album.cover.all, context.getString(R.string.desc_album_cover, album.name), R.drawable.ic_album_24) @@ -364,8 +357,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param artist The [Artist] to bind to the view. */ fun bind(artist: Artist) = - bind( - artist.songs, + bindImpl( + artist.cover.all, context.getString(R.string.desc_artist_image, artist.name), R.drawable.ic_artist_24) @@ -375,8 +368,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param genre The [Genre] to bind to the view. */ fun bind(genre: Genre) = - bind( - genre.songs, + bindImpl( + genre.cover.all, context.getString(R.string.desc_genre_image, genre.name), R.drawable.ic_genre_24) @@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param playlist the [Playlist] to bind. */ fun bind(playlist: Playlist) = - bind( - playlist.songs, + bindImpl( + playlist.cover?.all ?: emptyList(), context.getString(R.string.desc_playlist_image, playlist.name), R.drawable.ic_playlist_24) @@ -398,10 +391,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param desc The content description to describe the bound data. * @param errorRes The resource of the error drawable to use if the cover cannot be loaded. */ - fun bind(songs: Collection, desc: String, @DrawableRes errorRes: Int) { + fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) = + bindImpl(Cover.order(songs), desc, errorRes) + + private fun bindImpl(covers: List, desc: String, @DrawableRes errorRes: Int) { val request = ImageRequest.Builder(context) - .data(songs) + .data(covers) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) .target(image) @@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr CoilUtils.dispose(image) imageLoader.enqueue(request.build()) contentDescription = desc - currentCover = Cover(songs, desc, errorRes) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index b7a7183db..4e3083316 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -24,25 +24,23 @@ import coil.key.Keyer import coil.request.Options import coil.size.Size import javax.inject.Inject -import org.oxycblt.auxio.music.Song -class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : - Keyer> { - override fun key(data: Collection, options: Options) = - "${coverExtractor.computeCoverOrdering(data).hashCode()}" +class CoverKeyer @Inject constructor() : Keyer> { + override fun key(data: Collection, options: Options) = + "${data.map { it.perceptualHash }.hashCode()}" } -class SongCoverFetcher +class CoverFetcher private constructor( - private val songs: Collection, + private val covers: Collection, private val size: Size, private val coverExtractor: CoverExtractor, ) : Fetcher { - override suspend fun fetch() = coverExtractor.extract(songs, size) + override suspend fun fetch() = coverExtractor.extract(covers, size) class Factory @Inject constructor(private val coverExtractor: CoverExtractor) : - Fetcher.Factory> { - override fun create(data: Collection, options: Options, imageLoader: ImageLoader) = - SongCoverFetcher(data, options.size, coverExtractor) + Fetcher.Factory> { + override fun create(data: Collection, options: Options, imageLoader: ImageLoader) = + CoverFetcher(data, options.size, coverExtractor) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 3be13c02f..4595a0dcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -19,6 +19,8 @@ package org.oxycblt.auxio.image.extractor import android.net.Uri +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Song /** * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading @@ -29,4 +31,24 @@ import android.net.Uri * an album cover. * @author Alexander Capehart (OxygenCobalt) */ -data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) +data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) { + companion object { + private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) + + fun order(songs: Collection) = + FALLBACK_SORT.songs(songs) + .map { it.cover } + .groupBy { it.perceptualHash } + .entries + .sortedByDescending { it.value.size } + .map { it.value.first() } + } +} + +data class ParentCover(val single: Cover, val all: List) { + companion object { + fun from(song: Song, songs: Collection) = from(song.cover, songs) + + fun from(src: Cover, songs: Collection) = ParentCover(src, Cover.order(songs)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index bb8eef06a..556b11445 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -27,6 +27,7 @@ import android.util.Size as AndroidSize import androidx.core.graphics.drawable.toDrawable import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Metadata import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.source.MediaSource import androidx.media3.extractor.metadata.flac.PictureFrame @@ -50,8 +51,6 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logE @@ -70,14 +69,13 @@ constructor( /** * Extract an image (in the form of [FetchResult]) to represent the given [Song]s. * - * @param songs The [Song]s to load. + * @param covers The [Cover]s to load. * @param size The [Size] of the image to load. * @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult] * will be returned of a mosaic composed of four album covers ordered by * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. */ - suspend fun extract(songs: Collection, size: Size): FetchResult? { - val covers = computeCoverOrdering(songs) + suspend fun extract(covers: Collection, size: Size): FetchResult? { val streams = mutableListOf() for (cover in covers) { openCoverInputStream(cover)?.let(streams::add) @@ -108,19 +106,37 @@ constructor( dataSource = DataSource.DISK) } - /** - * Creates an [Album] list representing the order that album covers would be used in [extract]. - * - * @param songs A hypothetical list of [Song]s that would be used in [extract]. - * @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then - * by their names. "Representation" is defined by how many [Song]s were found to be linked to - * the given [Album] in the given [Song] list. - */ - fun computeCoverOrdering(songs: Collection) = - Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) - .songs(songs) - .distinctBy { (it.cover.perceptualHash ?: it.uri).toString() } - .map { it.cover } + fun findCoverDataInMetadata(metadata: Metadata): InputStream? { + var stream: ByteArrayInputStream? = null + + for (i in 0 until metadata.length()) { + // We can only extract pictures from two tags with this method, ID3v2's APIC or + // Vorbis picture comments. + val pic: ByteArray? + val type: Int + + when (val entry = metadata.get(i)) { + is ApicFrame -> { + pic = entry.pictureData + type = entry.pictureType + } + is PictureFrame -> { + pic = entry.pictureData + type = entry.pictureType + } + else -> continue + } + + if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { + stream = ByteArrayInputStream(pic) + break + } else if (stream == null) { + stream = ByteArrayInputStream(pic) + } + } + + return stream + } private suspend fun openCoverInputStream(cover: Cover) = try { @@ -165,35 +181,7 @@ constructor( return null } - var stream: ByteArrayInputStream? = null - - for (i in 0 until metadata.length()) { - // We can only extract pictures from two tags with this method, ID3v2's APIC or - // Vorbis picture comments. - val pic: ByteArray? - val type: Int - - when (val entry = metadata.get(i)) { - is ApicFrame -> { - pic = entry.pictureData - type = entry.pictureType - } - is PictureFrame -> { - pic = entry.pictureData - type = entry.pictureType - } - else -> continue - } - - if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { - stream = ByteArrayInputStream(pic) - break - } else if (stream == null) { - stream = ByteArrayInputStream(pic) - } - } - - return stream + return findCoverDataInMetadata(metadata) } private suspend fun extractMediaStoreCover(cover: Cover) = diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt new file mode 100644 index 000000000..0b0949efd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Auxio Project + * DHash.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image.extractor + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import java.math.BigInteger + +fun Bitmap.dHash(hashSize: Int = 16): String { + // Step 1: Resize the bitmap to a fixed size + val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true) + + // Step 2: Convert the bitmap to grayscale + val grayBitmap = + Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(grayBitmap) + val paint = Paint() + val colorMatrix = ColorMatrix() + colorMatrix.setSaturation(0f) + val filter = ColorMatrixColorFilter(colorMatrix) + paint.colorFilter = filter + canvas.drawBitmap(resizedBitmap, 0f, 0f, paint) + + // Step 3: Compute the difference between adjacent pixels + var hash = BigInteger.valueOf(0) + val one = BigInteger.valueOf(1) + for (y in 0 until hashSize) { + for (x in 0 until hashSize) { + val pixel1 = grayBitmap.getPixel(x, y) + val pixel2 = grayBitmap.getPixel(x + 1, y) + val diff = Color.red(pixel1) - Color.red(pixel2) + if (diff > 0) { + hash = hash.or(one.shl(y * hashSize + x)) + } + } + } + + return hash.toString(16) +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt index 5f4145479..44c4d3166 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -35,14 +35,14 @@ class ExtractorModule { @Provides fun imageLoader( @ApplicationContext context: Context, - songKeyer: SongKeyer, - songFactory: SongCoverFetcher.Factory + keyer: CoverKeyer, + factory: CoverFetcher.Factory ) = ImageLoader.Builder(context) .components { // Add fetchers for Music components to make them usable with ImageRequest - add(songKeyer) - add(songFactory) + add(keyer) + add(factory) } // Use our own crossfade with error drawable support .transitionFactory(ErrorCrossfadeTransitionFactory()) diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt similarity index 94% rename from app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt rename to app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt index ca94f66cf..3d51677e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * CoilBitmapLoader.kt is part of Auxio. + * MediaSessionBitmapLoader.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,7 +31,7 @@ import com.google.common.util.concurrent.SettableFuture import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.image.BitmapProvider -import org.oxycblt.auxio.image.extractor.SongKeyer +import org.oxycblt.auxio.image.extractor.CoverKeyer import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID @@ -41,7 +41,7 @@ constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, - private val songKeyer: SongKeyer, + private val keyer: CoverKeyer, private val imageLoader: ImageLoader, ) : BitmapLoader { override fun decodeBitmap(data: ByteArray): ListenableFuture { @@ -69,7 +69,7 @@ constructor( ?: return null // Even launching a coroutine to obtained cached covers is enough to make the notification // go without covers. - val key = songKeyer.key(listOf(song), Options(context)) + val key = keyer.key(listOf(song.cover), Options(context)) if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) return future diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index f3bf1bd06..359afd9c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -28,6 +28,7 @@ import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.image.extractor.Cover +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path @@ -296,7 +297,7 @@ interface Album : MusicParent { */ val releaseType: ReleaseType /** Cover information from the template song used for the album. */ - val cover: Cover + val cover: ParentCover /** The duration of all songs in the album, in milliseconds. */ val durationMs: Long /** The earliest date a song in this album was added, as a unix epoch timestamp. */ @@ -326,7 +327,7 @@ interface Artist : MusicParent { */ val durationMs: Long? /** Useful information to quickly obtain a (single) cover for a Genre. */ - val cover: Cover + val cover: ParentCover /** The [Genre]s of this artist. */ val genres: List } @@ -342,7 +343,7 @@ interface Genre : MusicParent { /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long /** Useful information to quickly obtain a (single) cover for a Genre. */ - val cover: Cover + val cover: ParentCover } /** @@ -356,7 +357,7 @@ interface Playlist : MusicParent { /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long /** Useful information to quickly obtain a (single) cover for a Genre. */ - val cover: Cover? + val cover: ParentCover? } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 63e4ccf98..00f8eb43b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 42, exportSchema = false) +@Database(entities = [CachedSong::class], version = 45, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } @@ -80,6 +80,8 @@ data class CachedSong( var subtitle: String? = null, /** @see RawSong.date */ var date: Date? = null, + /** @see RawSong.coverPerceptualHash */ + var coverPerceptualHash: String? = null, /** @see RawSong.albumMusicBrainzId */ var albumMusicBrainzId: String? = null, /** @see RawSong.albumName */ @@ -119,6 +121,8 @@ data class CachedSong( rawSong.subtitle = subtitle rawSong.date = date + rawSong.coverPerceptualHash = coverPerceptualHash + rawSong.albumMusicBrainzId = albumMusicBrainzId rawSong.albumName = albumName rawSong.albumSortName = albumSortName @@ -167,6 +171,7 @@ data class CachedSong( disc = rawSong.disc, subtitle = rawSong.subtitle, date = rawSong.date, + coverPerceptualHash = rawSong.coverPerceptualHash, albumMusicBrainzId = rawSong.albumMusicBrainzId, albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, albumSortName = rawSong.albumSortName, diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 53453eb98..6604c3579 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R import org.oxycblt.auxio.image.extractor.Cover +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -112,7 +113,8 @@ class SongImpl( override val genres: List get() = _genres - override val cover = Cover("", requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) + override val cover = + Cover(rawSong.coverPerceptualHash, requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an @@ -295,7 +297,7 @@ class AlbumImpl( override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val durationMs: Long override val dateAdded: Long - override val cover = grouping.raw.src.cover + override val cover: ParentCover private val _artists = mutableListOf() override val artists: List @@ -339,6 +341,8 @@ class AlbumImpl( durationMs = totalDuration dateAdded = earliestDateAdded + cover = ParentCover.from(grouping.raw.src.cover, songs) + hashCode = 31 * hashCode + rawAlbum.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() @@ -421,12 +425,7 @@ class ArtistImpl( override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? - override val cover = - when (val src = grouping.raw.src) { - is AlbumImpl -> src.cover - is SongImpl -> src.cover - else -> error("Unexpected input music $src in $name ${src::class.simpleName}") - } + override val cover: ParentCover override lateinit var genres: List @@ -459,6 +458,14 @@ class ArtistImpl( implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } durationMs = songs.sumOf { it.durationMs }.positiveOrNull() + val singleCover = + when (val src = grouping.raw.src) { + is SongImpl -> src.cover + is AlbumImpl -> src.cover.single + else -> error("Unexpected input source $src in $name ${src::class.simpleName}") + } + cover = ParentCover.from(singleCover, songs) + hashCode = 31 * hashCode + rawArtist.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() @@ -536,7 +543,7 @@ class GenreImpl( override val songs: Set override val artists: Set override val durationMs: Long - override val cover = grouping.raw.src.cover + override val cover: ParentCover private var hashCode = uid.hashCode() @@ -554,6 +561,8 @@ class GenreImpl( artists = distinctArtists durationMs = totalDuration + cover = ParentCover.from(grouping.raw.src.cover, songs) + hashCode = 31 * hashCode + rawGenre.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 2f3b6ec73..5b1b6df03 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -67,6 +67,8 @@ data class RawSong( var subtitle: String? = null, /** @see Song.date */ var date: Date? = null, + /** @see Song.cover */ + var coverPerceptualHash: String? = null, /** @see RawAlbum.mediaStoreId */ var albumMediaStoreId: Long? = null, /** @see RawAlbum.musicBrainzId */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 78669caee..202f364df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music.metadata +import android.graphics.BitmapFactory import androidx.core.text.isDigitsOnly import androidx.media3.common.MediaItem import androidx.media3.exoplayer.MetadataRetriever @@ -25,6 +26,8 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject +import org.oxycblt.auxio.image.extractor.CoverExtractor +import org.oxycblt.auxio.image.extractor.dHash import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.info.Date @@ -60,7 +63,10 @@ interface TagWorker { class TagWorkerFactoryImpl @Inject -constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory { +constructor( + private val mediaSourceFactory: MediaSource.Factory, + private val coverExtractor: CoverExtractor +) : TagWorker.Factory { override fun create(rawSong: RawSong): TagWorker = // Note that we do not leverage future callbacks. This is because errors in the // (highly fallible) extraction process will not bubble up to Indexer when a @@ -70,12 +76,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac MetadataRetriever.retrieveMetadata( mediaSourceFactory, MediaItem.fromUri( - requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) + requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())), + coverExtractor) } private class TagWorkerImpl( private val rawSong: RawSong, - private val future: Future + private val future: Future, + private val coverExtractor: CoverExtractor ) : TagWorker { override fun poll(): RawSong? { if (!future.isDone) { @@ -98,6 +106,11 @@ private class TagWorkerImpl( populateWithId3v2(textTags.id3v2) populateWithVorbis(textTags.vorbis) + val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata) + val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } + rawSong.coverPerceptualHash = bitmap?.dHash() + bitmap?.recycle() + // OPUS base gain interpretation code: This is likely not needed, as the media player // should be using the base gain already. Uncomment if that's not the case. // if (format.sampleMimeType == MimeTypes.AUDIO_OPUS diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 3e05e4118..776fed706 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) - .setArtworkUri(album.cover.mediaStoreUri) + .setArtworkUri(album.cover.single.mediaStoreUri) .setExtras( Bundle().apply { putString("uid", mediaSessionUID.toString()) @@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { .setIsPlayable(true) .setIsBrowsable(true) .setGenre(genres.resolveNames(context)) - .setArtworkUri(cover.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover?.mediaStoreUri) + .setArtworkUri(cover?.single?.mediaStoreUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index 6d53bb41b..2e12f1bff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music.user +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist @@ -46,7 +47,7 @@ private constructor( override fun toString() = "Playlist(uid=$uid, name=$name)" - override val cover = songs.firstOrNull()?.cover + override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) } /** * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. From 657b8267f19cec9777052f0a3bbd413d3d17810b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 Apr 2024 14:54:26 -0600 Subject: [PATCH 071/110] list: clamp item drag speed Resolves #686 --- .../list/recycler/MaterialDragCallback.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt index 28112ca61..97eed4987 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sign import org.oxycblt.auxio.R import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder import org.oxycblt.auxio.util.getDimen @@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { 0 } + override fun interpolateOutOfBoundsScroll( + recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long + ): Int { + // Clamp the scroll speed to prevent thefrom freaking out + // Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe + val standardSpeed = + super.interpolateOutOfBoundsScroll( + recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll) + + val clampedAbsVelocity = + max( + MINIMUM_INITIAL_DRAG_VELOCITY, + min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)) + + return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + final override fun onChildDraw( c: Canvas, recyclerView: RecyclerView, @@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { /** The drawable of the [body] background that can be elevated. */ val background: Drawable } + + companion object { + const val MINIMUM_INITIAL_DRAG_VELOCITY = 10 + const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25 + } } From e68765887439bfb658b732e6af768a99bc81b251 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 22 Apr 2024 08:36:59 -0600 Subject: [PATCH 072/110] image: properly handle uniqueness of non-embedded covers Use a UID instead. This is non-ideal but all we can do. --- .../org/oxycblt/auxio/image/extractor/Components.kt | 2 +- .../java/org/oxycblt/auxio/image/extractor/Cover.kt | 11 +++++++++-- .../org/oxycblt/auxio/music/device/DeviceMusicImpl.kt | 6 +++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 4e3083316..0bf1de4a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -27,7 +27,7 @@ import javax.inject.Inject class CoverKeyer @Inject constructor() : Keyer> { override fun key(data: Collection, options: Options) = - "${data.map { it.perceptualHash }.hashCode()}" + "${data.map { it.uniqueness }.hashCode()}" } class CoverFetcher diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 4595a0dcf..bb0b6197f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.image.extractor import android.net.Uri import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song /** @@ -31,14 +32,20 @@ import org.oxycblt.auxio.music.Song * an album cover. * @author Alexander Capehart (OxygenCobalt) */ -data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) { +data class Cover(val uniqueness: Uniqueness?, val mediaStoreUri: Uri, val songUri: Uri) { + sealed interface Uniqueness { + data class PerceptualHash(val perceptualHash: String) : Uniqueness + + data class UID(val uid: Music.UID) : Uniqueness + } + companion object { private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) fun order(songs: Collection) = FALLBACK_SORT.songs(songs) .map { it.cover } - .groupBy { it.perceptualHash } + .groupBy { it.uniqueness } .entries .sortedByDescending { it.value.size } .map { it.value.first() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 6604c3579..96ef5fa3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -114,7 +114,11 @@ class SongImpl( get() = _genres override val cover = - Cover(rawSong.coverPerceptualHash, requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) + Cover( + rawSong.coverPerceptualHash?.let { Cover.Uniqueness.PerceptualHash(it) } + ?: Cover.Uniqueness.UID(uid), + requireNotNull(rawSong.mediaStoreId).toCoverUri(), + uri) /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an From a4838cefaa5efb20e2d0e801e461141b1579455d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 22 Apr 2024 10:44:03 -0600 Subject: [PATCH 073/110] image: properly differentiate cover types - If we could find an embedded cover, then we can treat it as a per-song cover - Otherwise, just do our old album-based behavior. --- .../auxio/image/extractor/Components.kt | 2 +- .../oxycblt/auxio/image/extractor/Cover.kt | 35 +++++++++++-------- .../auxio/image/extractor/CoverExtractor.kt | 23 +++++++----- .../auxio/music/device/DeviceMusicImpl.kt | 27 +++++++++----- .../org/oxycblt/auxio/music/fs/StorageUtil.kt | 6 ++-- 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 0bf1de4a2..f38d00695 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -27,7 +27,7 @@ import javax.inject.Inject class CoverKeyer @Inject constructor() : Keyer> { override fun key(data: Collection, options: Options) = - "${data.map { it.uniqueness }.hashCode()}" + "${data.map { it.key }.hashCode()}" } class CoverFetcher diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index bb0b6197f..7e64252f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -20,23 +20,28 @@ package org.oxycblt.auxio.image.extractor import android.net.Uri import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -/** - * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading - * images. - * - * @param mediaStoreUri The album cover [Uri] obtained from MediaStore. - * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain - * an album cover. - * @author Alexander Capehart (OxygenCobalt) - */ -data class Cover(val uniqueness: Uniqueness?, val mediaStoreUri: Uri, val songUri: Uri) { - sealed interface Uniqueness { - data class PerceptualHash(val perceptualHash: String) : Uniqueness +sealed interface Cover { + val key: String + val mediaStoreCoverUri: Uri - data class UID(val uid: Music.UID) : Uniqueness + /** + * The song has an embedded cover art we support, so we can operate with it on a per-song + * basis. + */ + data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) : Cover { + override val mediaStoreCoverUri = songCoverUri + override val key = perceptualHash + } + + /** + * We couldn't find any embedded cover art ourselves, but the android system might have some + * through a cover.jpg file or something similar. + */ + data class External(val albumCoverUri: Uri) : Cover { + override val mediaStoreCoverUri = albumCoverUri + override val key = albumCoverUri.toString() } companion object { @@ -45,7 +50,7 @@ data class Cover(val uniqueness: Uniqueness?, val mediaStoreUri: Uri, val songUr fun order(songs: Collection) = FALLBACK_SORT.songs(songs) .map { it.cover } - .groupBy { it.uniqueness } + .groupBy { it.key } .entries .sortedByDescending { it.value.size } .map { it.value.first() } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 556b11445..3a6a337ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -140,21 +140,28 @@ constructor( private suspend fun openCoverInputStream(cover: Cover) = try { - when (imageSettings.coverMode) { - CoverMode.OFF -> null - CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover) - CoverMode.QUALITY -> extractQualityCover(cover) + when (cover) { + is Cover.Embedded -> + when (imageSettings.coverMode) { + CoverMode.OFF -> null + CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover) + CoverMode.QUALITY -> extractQualityCover(cover) + } + + is Cover.External -> { + extractMediaStoreCover(cover) + } } } catch (e: Exception) { logE("Unable to extract album cover due to an error: $e") null } - private suspend fun extractQualityCover(cover: Cover) = + private suspend fun extractQualityCover(cover: Cover.Embedded) = extractAospMetadataCover(cover) ?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover) - private fun extractAospMetadataCover(cover: Cover): InputStream? = + private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? = MediaMetadataRetriever().run { // This call is time-consuming but it also doesn't seem to hold up the main thread, // so it's probably fine not to wrap it.rmt @@ -166,7 +173,7 @@ constructor( embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } } - private suspend fun extractExoplayerCover(cover: Cover): InputStream? { + private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? { val tracks = MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri)) .asDeferred() @@ -186,7 +193,7 @@ constructor( private suspend fun extractMediaStoreCover(cover: Cover) = // Eliminate any chance that this blocking call might mess up the loading process - withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreUri) } + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreCoverUri) } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ private suspend fun createMosaic(streams: List, size: Size): FetchResult { diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 96ef5fa3c..dcfda228f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R @@ -29,8 +29,9 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.MimeType +import org.oxycblt.auxio.music.fs.toAlbumCoverUri import org.oxycblt.auxio.music.fs.toAudioUri -import org.oxycblt.auxio.music.fs.toCoverUri +import org.oxycblt.auxio.music.fs.toSongCoverUri import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name @@ -76,7 +77,8 @@ class SongImpl( override val name = nameFactory.parse( requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" }, - rawSong.sortName) + rawSong.sortName + ) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } @@ -114,11 +116,20 @@ class SongImpl( get() = _genres override val cover = - Cover( - rawSong.coverPerceptualHash?.let { Cover.Uniqueness.PerceptualHash(it) } - ?: Cover.Uniqueness.UID(uid), - requireNotNull(rawSong.mediaStoreId).toCoverUri(), - uri) + rawSong.coverPerceptualHash?.let { + // We were able to confirm that the song had a parsable cover and can be used on + // a per-song basis. Otherwise, just fall back to a per-album cover instead, as + // it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not + // support the cover metadata of a given spec (unlikely). + Cover.Embedded( + requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toSongCoverUri(), + uid, + it + ) + } + ?: Cover.External( + requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri() + ) /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt index da61d613b..6cdc7df67 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt @@ -102,13 +102,15 @@ fun Long.toAudioUri() = * @return An external storage image [Uri]. May not exist. * @see ContentUris.withAppendedId */ -fun Long.toCoverUri(): Uri = +fun Long.toSongCoverUri(): Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run { - appendPath(this@toCoverUri.toString()) + appendPath(this@toSongCoverUri.toString()) appendPath("albumart") build() } +fun Long.toAlbumCoverUri(): Uri = ContentUris.withAppendedId(externalCoversUri, this) + // --- STORAGEMANAGER UTILITIES --- // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles From aec08bb48b452b8c8ea51febc7e4f93e0c16a66d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 22 Apr 2024 10:46:44 -0600 Subject: [PATCH 074/110] all: reformat/fixes --- .../main/java/org/oxycblt/auxio/AuxioService.kt | 4 ++-- .../org/oxycblt/auxio/image/extractor/Cover.kt | 6 +++--- .../auxio/image/extractor/CoverExtractor.kt | 5 +++-- .../auxio/list/recycler/MaterialDragCallback.kt | 2 +- .../auxio/music/device/DeviceMusicImpl.kt | 17 +++++++---------- ...erComponent.kt => IndexerServiceFragment.kt} | 4 ++-- .../auxio/music/service/MediaItemTranslation.kt | 10 +++++----- 7 files changed, 23 insertions(+), 25 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/service/{IndexerComponent.kt => IndexerServiceFragment.kt} (98%) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 8178a8218..64121e6d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -26,14 +26,14 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import org.oxycblt.auxio.music.service.IndexingServiceFragment +import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment @AndroidEntryPoint class AuxioService : MediaLibraryService(), ForegroundListener { @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment - @Inject lateinit var indexingFragment: IndexingServiceFragment + @Inject lateinit var indexingFragment: IndexerServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 7e64252f9..bf3cf97cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -27,10 +27,10 @@ sealed interface Cover { val mediaStoreCoverUri: Uri /** - * The song has an embedded cover art we support, so we can operate with it on a per-song - * basis. + * The song has an embedded cover art we support, so we can operate with it on a per-song basis. */ - data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) : Cover { + data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) : + Cover { override val mediaStoreCoverUri = songCoverUri override val key = perceptualHash } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 3a6a337ea..d28979b6b 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -147,7 +147,6 @@ constructor( CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover) CoverMode.QUALITY -> extractQualityCover(cover) } - is Cover.External -> { extractMediaStoreCover(cover) } @@ -193,7 +192,9 @@ constructor( private suspend fun extractMediaStoreCover(cover: Cover) = // Eliminate any chance that this blocking call might mess up the loading process - withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreCoverUri) } + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(cover.mediaStoreCoverUri) + } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ private suspend fun createMosaic(streams: List, size: Size): FetchResult { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt index 97eed4987..6ccf789b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -64,7 +64,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { totalSize: Int, msSinceStartScroll: Long ): Int { - // Clamp the scroll speed to prevent thefrom freaking out + // Clamp the scroll speed to prevent the lists from freaking out // Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe val standardSpeed = super.interpolateOutOfBoundsScroll( diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index dcfda228f..674c0cb75 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R @@ -77,8 +77,7 @@ class SongImpl( override val name = nameFactory.parse( requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" }, - rawSong.sortName - ) + rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } @@ -122,14 +121,12 @@ class SongImpl( // it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not // support the cover metadata of a given spec (unlikely). Cover.Embedded( - requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toSongCoverUri(), - uid, - it - ) + requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" } + .toSongCoverUri(), + uri, + it) } - ?: Cover.External( - requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri() - ) + ?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri()) /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt index 401b49145..6362def6b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerComponent.kt is part of Auxio. + * IndexerServiceFragment.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -35,7 +35,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class IndexingServiceFragment +class IndexerServiceFragment @Inject constructor( @ApplicationContext override val workerContext: Context, diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 776fed706..2e92157b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) - .setArtworkUri(album.cover.single.mediaStoreUri) + .setArtworkUri(album.cover.single.mediaStoreCoverUri) .setExtras( Bundle().apply { putString("uid", mediaSessionUID.toString()) @@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { .setIsPlayable(true) .setIsBrowsable(true) .setGenre(genres.resolveNames(context)) - .setArtworkUri(cover.single.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreUri) + .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() @@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setIsPlayable(true) .setIsBrowsable(true) - .setArtworkUri(cover?.single?.mediaStoreUri) + .setArtworkUri(cover?.single?.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .build() return MediaItem.Builder() From f23d1a8eafd682a71c5c5ef777c1696d2eb97120 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 29 Apr 2024 11:09:08 -0600 Subject: [PATCH 075/110] build: update media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 6c77cfa13..1d58171e1 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 6c77cfa13c83bf2ae5188603d2c9a51ec4cb3ac3 +Subproject commit 1d58171e16107d73ec3c842319663a8a06bfd23a From 66db61899c70512990cc20291fac8ef45ac7dd20 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 13:38:12 -0600 Subject: [PATCH 076/110] playback: remove custom bitmap loading Media3 simply will not tolerate me doing this. I am basically stuck at the mercy of the Android OS now, until I can have my own unified source of truth with cover loading. --- .../image/service/MediaSessionBitmapLoader.kt | 90 ------------------- .../music/service/MediaItemTranslation.kt | 2 +- .../service/ExoPlaybackStateHolder.kt | 18 ++-- .../service/MediaSessionServiceFragment.kt | 6 +- build.gradle | 2 +- 5 files changed, 16 insertions(+), 102 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt deleted file mode 100644 index 3d51677e6..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionBitmapLoader.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.image.service - -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import androidx.media3.common.MediaMetadata -import androidx.media3.common.util.BitmapLoader -import coil.ImageLoader -import coil.memory.MemoryCache -import coil.request.Options -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.SettableFuture -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import org.oxycblt.auxio.image.BitmapProvider -import org.oxycblt.auxio.image.extractor.CoverKeyer -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.service.MediaSessionUID - -class MediaSessionBitmapLoader -@Inject -constructor( - @ApplicationContext private val context: Context, - private val musicRepository: MusicRepository, - private val bitmapProvider: BitmapProvider, - private val keyer: CoverKeyer, - private val imageLoader: ImageLoader, -) : BitmapLoader { - override fun decodeBitmap(data: ByteArray): ListenableFuture { - throw NotImplementedError() - } - - override fun loadBitmap(uri: Uri): ListenableFuture { - throw NotImplementedError() - } - - override fun supportsMimeType(mimeType: String): Boolean { - return true - } - - override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val future = SettableFuture.create() - val song = - when (val uid = - metadata.extras?.getString("uid")?.let { MediaSessionUID.fromString(it) }) { - is MediaSessionUID.Single -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.Joined -> deviceLibrary.findSong(uid.childUid) - else -> return null - } - ?: return null - // Even launching a coroutine to obtained cached covers is enough to make the notification - // go without covers. - val key = keyer.key(listOf(song.cover), Options(context)) - if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { - future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) - return future - } - bitmapProvider.load( - song, - object : BitmapProvider.Target { - override fun onCompleted(bitmap: Bitmap?) { - if (bitmap == null) { - future.setException(IllegalStateException("Bitmap is null")) - } else { - future.set(bitmap) - } - } - }) - return future - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 2e92157b2..bde25c1b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setIsPlayable(true) .setIsBrowsable(false) - .setArtworkUri(album.cover.single.mediaStoreCoverUri) + .setArtworkUri(cover.mediaStoreCoverUri) .setExtras( Bundle().apply { putString("uid", mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 070432bc8..e8dca68be 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.service import android.content.Context import android.content.Intent import android.media.audiofx.AudioEffect +import android.os.Bundle import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -42,6 +43,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song @@ -69,13 +71,15 @@ class ExoPlaybackStateHolder( private val persistenceRepository: PersistenceRepository, private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor, private val musicRepository: MusicRepository, - private val replayGainProcessor: ReplayGainAudioProcessor + private val imageSettings: ImageSettings ) : PlaybackStateHolder, Player.Listener, MusicRepository.UpdateListener, - PlaybackSettings.Listener { + PlaybackSettings.Listener, + ImageSettings.Listener { private val saveJob = Job() private val saveScope = CoroutineScope(Dispatchers.IO + saveJob) private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob) @@ -86,6 +90,7 @@ class ExoPlaybackStateHolder( private set fun attach() { + imageSettings.registerListener(this) player.addListener(this) replayGainProcessor.attach() playbackManager.registerStateHolder(this) @@ -99,6 +104,7 @@ class ExoPlaybackStateHolder( playbackManager.unregisterStateHolder(this) musicRepository.removeUpdateListener(this) replayGainProcessor.release() + imageSettings.unregisterListener(this) player.release() } @@ -516,9 +522,10 @@ class ExoPlaybackStateHolder( private val persistenceRepository: PersistenceRepository, private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository, private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessor: ReplayGainAudioProcessor + private val replayGainProcessor: ReplayGainAudioProcessor, + private val musicRepository: MusicRepository, + private val imageSettings: ImageSettings, ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save @@ -556,8 +563,9 @@ class ExoPlaybackStateHolder( persistenceRepository, playbackSettings, commandFactory, + replayGainProcessor, musicRepository, - replayGainProcessor) + imageSettings) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index a626cd4b6..8152b1053 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -48,7 +48,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.service.MediaSessionBitmapLoader import org.oxycblt.auxio.music.service.MediaItemBrowser import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager @@ -61,7 +60,6 @@ constructor( private val playbackManager: PlaybackStateManager, private val actionHandler: PlaybackActionHandler, private val mediaItemBrowser: MediaItemBrowser, - private val bitmapLoader: MediaSessionBitmapLoader, exoHolderFactory: ExoPlaybackStateHolder.Factory ) : MediaLibrarySession.Callback, @@ -143,9 +141,7 @@ constructor( } private fun createSession(service: MediaLibraryService) = - MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this) - .setBitmapLoader(bitmapLoader) - .build() + MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build() override fun onConnect( session: MediaSession, diff --git a/build.gradle b/build.gradle index 2b2c7a9c5..a81768d25 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version '8.3.2' apply false + id "com.android.application" version '8.4.0' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.23-1.0.20' apply false From 8e5d061af5500f506a4caa457aae8cf77472d992 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 16:18:01 -0600 Subject: [PATCH 077/110] playback: re-add old swap move Turns out this did have a reason to exist, ExoPlayer doesn't have intrinsic capabilities to update the shuffle order on moves. --- .../oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index e8dca68be..8413738b9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.playback.service import android.content.Context import android.content.Intent import android.media.audiofx.AudioEffect -import android.os.Bundle import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -329,12 +328,16 @@ class ExoPlaybackStateHolder( val trueFrom = indices[from] val trueTo = indices[to] + // ExoPlayer does not actually update it's ShuffleOrder when moving items. Retain a + // semblance of "normalcy" by doing a weird no-op swap that actually moves the item. when { trueFrom > trueTo -> { player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo + 1, trueFrom) } trueTo > trueFrom -> { player.moveMediaItem(trueFrom, trueTo) + player.moveMediaItem(trueTo - 1, trueFrom) } } playbackManager.ack(this, ack) From e1e1e63dbbf79fab1c20d877e4a2d016a0a96b5e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 19:00:38 -0600 Subject: [PATCH 078/110] playback: basic tasker plugin No idea if this works. Should be helpful for testing more service independence stuff. --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 13 +++ .../java/org/oxycblt/auxio/AuxioService.kt | 4 + .../java/org/oxycblt/auxio/tasker/Start.kt | 79 +++++++++++++++++++ .../java/org/oxycblt/auxio/tasker/Tasker.kt | 19 ----- 5 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/tasker/Start.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt diff --git a/app/build.gradle b/app/build.gradle index e43141a3c..f74962d9c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,6 +151,9 @@ dependencies { // Speed dial implementation "com.leinardi.android:speed-dial:3.3.0" + // Tasker + implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 53c1b4e2b..a65f67788 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -134,5 +134,18 @@ android:name="android.appwidget.provider" android:resource="@xml/widget_info" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 64121e6d1..fd44f6a5b 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -28,6 +28,8 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment +import org.oxycblt.auxio.tasker.indicateServiceRunning +import org.oxycblt.auxio.tasker.indicateServiceStopped @AndroidEntryPoint class AuxioService : MediaLibraryService(), ForegroundListener { @@ -40,6 +42,7 @@ class AuxioService : MediaLibraryService(), ForegroundListener { super.onCreate() mediaSessionFragment.attach(this, this) indexingFragment.attach(this) + indicateServiceRunning() } override fun onBind(intent: Intent?): IBinder? { @@ -70,6 +73,7 @@ class AuxioService : MediaLibraryService(), ForegroundListener { override fun onDestroy() { super.onDestroy() + indicateServiceStopped() indexingFragment.release() mediaSessionFragment.release() } diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt new file mode 100644 index 000000000..0191e0133 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Auxio Project + * Start.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.tasker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.ContextCompat +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import org.oxycblt.auxio.AuxioService + +private var serviceRunning = false + +fun indicateServiceRunning() { + serviceRunning = true +} + +fun indicateServiceStopped() { + serviceRunning = false +} + +class StartActionHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelperNoOutputOrInput(config) { + override val runnerClass: Class + get() = StartActionRunner::class.java + + override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { + blurbBuilder.append( + "Starts the Auxio Service. This will block until the service is fully initialized." + + "You must start active playback/foreground state after this or Auxio may" + + "crash.") + } +} + +class StartConfigBasicAction : Activity(), TaskerPluginConfigNoInput { + override val context: Context + get() = applicationContext + + private val taskerHelper by lazy { StartActionHelper(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + taskerHelper.finishForTasker() + } +} + +class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() { + override fun run(context: Context, input: TaskerInput): TaskerPluginResult { + ContextCompat.startForegroundService(context, Intent(context, AuxioService::class.java)) + while (!serviceRunning) { + Thread.sleep(100) + } + + return TaskerPluginResultSucess() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt deleted file mode 100644 index ec2d6ac99..000000000 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * Tasker.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.tasker From d21a7eee9340ca14ea2395452ad3adf1a9fd6f78 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 19:31:33 -0600 Subject: [PATCH 079/110] playback: more coherent notif action setup --- .../service/MediaSessionServiceFragment.kt | 4 ++ .../playback/service/PlaybackActionHandler.kt | 37 ------------------- media | 2 +- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 8152b1053..0ac779b04 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -76,6 +76,10 @@ constructor( .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") .setChannelName(R.string.lbl_playback) + .setPlayDrawableResourceId(R.drawable.ic_play_24) + .setPauseDrawableResourceId(R.drawable.ic_pause_24) + .setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24) + .setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24) .build() .also { it.setSmallIcon(R.drawable.ic_auxio_24) } private var foregroundListener: ForegroundListener? = null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 7aa37e36b..8597770de 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -27,7 +27,6 @@ import android.os.Bundle import androidx.core.content.ContextCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommands import dagger.hilt.android.qualifiers.ApplicationContext @@ -106,12 +105,6 @@ constructor( .setSessionCommand( SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) .setEnabled(true) - .setExtras( - Bundle().apply { - putInt( - DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, - 0) - }) .build()) } ActionMode.SHUFFLE -> { @@ -135,36 +128,6 @@ constructor( .setDisplayName(context.getString(R.string.desc_skip_prev)) .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) .setEnabled(true) - .setExtras( - Bundle().apply { - putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) - }) - .build()) - - actions.add( - CommandButton.Builder() - .setIconResId( - if (playbackManager.progression.isPlaying) R.drawable.ic_pause_24 - else R.drawable.ic_play_24) - .setDisplayName(context.getString(R.string.desc_play_pause)) - .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) - .setEnabled(true) - .setExtras( - Bundle().apply { - putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) - }) - .build()) - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_skip_next_24) - .setDisplayName(context.getString(R.string.desc_skip_next)) - .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) - .setEnabled(true) - .setExtras( - Bundle().apply { - putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 3) - }) .build()) actions.add( diff --git a/media b/media index 1d58171e1..9d84bc235 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 1d58171e16107d73ec3c842319663a8a06bfd23a +Subproject commit 9d84bc2351a21b768a9649bb40a45412259e2cda From 189cc63de771b987cdbb5fc92700a5664028ffee Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 19:50:12 -0600 Subject: [PATCH 080/110] music: fix incorrect mp4 sort tag interpretation --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 9d84bc235..f445eca7d 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 9d84bc2351a21b768a9649bb40a45412259e2cda +Subproject commit f445eca7d09132fa80af301cfa58c4160f958d94 From 9b7053ab7eed8a90089aadccaa392d26e9d3a191 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 19:53:03 -0600 Subject: [PATCH 081/110] ui: fix broken selection on editable song --- app/src/main/res/layout/item_editable_song.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/item_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index dbda5a44a..d49f38c95 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -32,7 +32,7 @@ android:id="@+id/interact_body" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple"> + android:background="@drawable/ui_item_bg"> Date: Fri, 17 May 2024 20:07:10 -0600 Subject: [PATCH 082/110] info: update changelog --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da465569a..3e669761a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog -## dev +## 3.5.0 + +#### What's New +- Android Auto support +- Full media browser implementation +- Service can now operate independently of app +- Added basic tasker plugin + +#### What's Improved +- Album covers are now loaded on a per-song basis + +#### What's Fixed +- Fixed repeat mode not restoring on startup + +#### What's Changed +- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings +- Playback will close automatically after some time left idle + +## 3.4.3 #### What's Improved - Added back option disable ReplayGain for poorly tagged libraries From 830ac34b67f5da31aecbd270b735a91859c13bd9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 20:08:04 -0600 Subject: [PATCH 083/110] build: bump to 3.5.0-dev --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f74962d9c..8071688a0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.4.3" - versionCode 44 + versionName "3.5.0-dev" + versionCode 45 minSdk 24 targetSdk 34 From 51309ebabbd3922449334e9a80879ddb6da91cf4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 21:07:41 -0600 Subject: [PATCH 084/110] tasker: plugin tweaks --- app/src/main/AndroidManifest.xml | 2 +- app/src/main/java/org/oxycblt/auxio/tasker/Start.kt | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a65f67788..d5c61525b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -141,7 +141,7 @@ android:name=".tasker.StartConfigBasicAction" android:exported="true" android:icon="@mipmap/ic_launcher" - android:label="My Tasker Action"> + android:label="Start Auxio"> diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt index 0191e0133..9e27486c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt @@ -50,8 +50,8 @@ class StartActionHelper(config: TaskerPluginConfig) : override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { blurbBuilder.append( "Starts the Auxio Service. This will block until the service is fully initialized." + - "You must start active playback/foreground state after this or Auxio may" + - "crash.") + "You must start active playback/foreground state after this or Auxio may" + + "crash.") } } @@ -70,10 +70,7 @@ class StartConfigBasicAction : Activity(), TaskerPluginConfigNoInput { class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() { override fun run(context: Context, input: TaskerInput): TaskerPluginResult { ContextCompat.startForegroundService(context, Intent(context, AuxioService::class.java)) - while (!serviceRunning) { - Thread.sleep(100) - } - + while (!serviceRunning) {} return TaskerPluginResultSucess() } } From 4d1df85b5c7e31e4962c2841b9b3919368253c6c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 17 May 2024 21:44:11 -0600 Subject: [PATCH 085/110] ui: fix broken editable song bg --- app/src/main/res/drawable/ui_selection_bg.xml | 5 +++++ app/src/main/res/layout/item_editable_song.xml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ui_selection_bg.xml diff --git a/app/src/main/res/drawable/ui_selection_bg.xml b/app/src/main/res/drawable/ui_selection_bg.xml new file mode 100644 index 000000000..45a1fd8a1 --- /dev/null +++ b/app/src/main/res/drawable/ui_selection_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index d49f38c95..70567899b 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -32,7 +32,7 @@ android:id="@+id/interact_body" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_bg"> + android:background="@drawable/ui_selection_bg"> Date: Fri, 17 May 2024 22:05:17 -0600 Subject: [PATCH 086/110] about: remove yrliet sponsor --- .../java/org/oxycblt/auxio/settings/AboutFragment.kt | 4 ---- app/src/main/res/layout/fragment_about.xml | 12 ------------ app/src/main/res/values/donottranslate.xml | 1 - 3 files changed, 17 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index 2ece33656..a80bc446d 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -68,9 +68,6 @@ class AboutFragment : ViewBindingFragment() { binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } binding.aboutProfile.setOnClickListener { requireContext().openInBrowser(LINK_PROFILE) } binding.aboutDonate.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) } - binding.aboutSupporterYrliet.setOnClickListener { - requireContext().openInBrowser(LINK_YRLIET) - } binding.aboutSupportersPromo.setOnClickListener { requireContext().openInBrowser(LINK_DONATE) } @@ -100,6 +97,5 @@ class AboutFragment : ViewBindingFragment() { const val LINK_LICENSES = "$LINK_WIKI/Licenses" const val LINK_PROFILE = "https://github.com/OxygenCobalt" const val LINK_DONATE = "https://github.com/sponsors/OxygenCobalt" - const val LINK_YRLIET = "https://github.com/yrliet" } } diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 06211fab4..787fcb546 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -224,18 +224,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - - Microsoft WAVE - yrliet \ No newline at end of file From b955e2f3abd5238673df80fa6fcdd099e6fceada Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 18 May 2024 11:35:30 -0600 Subject: [PATCH 087/110] playback: re-add notif content intent --- .../auxio/playback/service/MediaSessionServiceFragment.kt | 2 ++ media | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 0ac779b04..fb884cee3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -52,6 +52,7 @@ import org.oxycblt.auxio.music.service.MediaItemBrowser import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.newMainPendingIntent class MediaSessionServiceFragment @Inject @@ -80,6 +81,7 @@ constructor( .setPauseDrawableResourceId(R.drawable.ic_pause_24) .setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24) .setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24) + .setContentIntent(context.newMainPendingIntent()) .build() .also { it.setSmallIcon(R.drawable.ic_auxio_24) } private var foregroundListener: ForegroundListener? = null diff --git a/media b/media index f445eca7d..00124cbac 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit f445eca7d09132fa80af301cfa58c4160f958d94 +Subproject commit 00124cbac493c06a24e19b01893946bdaf8faf58 From 0f691ee65becd0b64479d3d01409333b08bbf4bd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 18 May 2024 17:34:42 -0600 Subject: [PATCH 088/110] tasker: remove Can't do this right now, lifecycle is broken. --- CHANGELOG.md | 7 +- app/build.gradle | 3 - app/src/main/AndroidManifest.xml | 12 --- .../java/org/oxycblt/auxio/tasker/Start.kt | 76 ------------------- 4 files changed, 5 insertions(+), 93 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/tasker/Start.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e669761a..3907a19ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,10 @@ #### What's New - Android Auto support - Full media browser implementation -- Service can now operate independently of app -- Added basic tasker plugin #### What's Improved - Album covers are now loaded on a per-song basis +- Correctly interpret MP4 sort tags #### What's Fixed - Fixed repeat mode not restoring on startup @@ -18,6 +17,10 @@ - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings - Playback will close automatically after some time left idle +#### dev -> dev1 changes +- Re-added ability to open app from clicking on notification +- Removed tasker plugin + ## 3.4.3 #### What's Improved diff --git a/app/build.gradle b/app/build.gradle index 8071688a0..f5bf46f91 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,9 +151,6 @@ dependencies { // Speed dial implementation "com.leinardi.android:speed-dial:3.3.0" - // Tasker - implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' - // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5c61525b..960d2c8ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,17 +135,5 @@ android:resource="@xml/widget_info" /> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt deleted file mode 100644 index 9e27486c6..000000000 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Start.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * Start.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.tasker - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.content.ContextCompat -import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput -import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig -import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput -import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput -import com.joaomgcd.taskerpluginlibrary.input.TaskerInput -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess -import org.oxycblt.auxio.AuxioService - -private var serviceRunning = false - -fun indicateServiceRunning() { - serviceRunning = true -} - -fun indicateServiceStopped() { - serviceRunning = false -} - -class StartActionHelper(config: TaskerPluginConfig) : - TaskerPluginConfigHelperNoOutputOrInput(config) { - override val runnerClass: Class - get() = StartActionRunner::class.java - - override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) { - blurbBuilder.append( - "Starts the Auxio Service. This will block until the service is fully initialized." + - "You must start active playback/foreground state after this or Auxio may" + - "crash.") - } -} - -class StartConfigBasicAction : Activity(), TaskerPluginConfigNoInput { - override val context: Context - get() = applicationContext - - private val taskerHelper by lazy { StartActionHelper(this) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - taskerHelper.finishForTasker() - } -} - -class StartActionRunner : TaskerPluginRunnerActionNoOutputOrInput() { - override fun run(context: Context, input: TaskerInput): TaskerPluginResult { - ContextCompat.startForegroundService(context, Intent(context, AuxioService::class.java)) - while (!serviceRunning) {} - return TaskerPluginResultSucess() - } -} From 27e39b6c10d3f3d9fc0f0fa5ac9b4d7b323292f4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 26 May 2024 21:50:34 -0600 Subject: [PATCH 089/110] music: interpret m3u paths as relative & absolute Resolves #673 --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 3 +- .../music/external/ExternalPlaylistManager.kt | 4 +- .../org/oxycblt/auxio/music/external/M3U.kt | 55 +++++++++++++------ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 4c45dfc84..afcb572bf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -164,7 +164,8 @@ constructor( } val deviceLibrary = musicRepository.deviceLibrary ?: return@launch - val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + val songs = importedPlaylist.paths.mapNotNull { + it.firstNotNullOfOrNull(deviceLibrary::findSongByPath) } if (songs.isEmpty()) { logE("No songs found") diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt index 1cf0b0810..a1171efee 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean) * @see ExternalPlaylistManager * @see M3U */ -data class ImportedPlaylist(val name: String?, val paths: List) +data class ImportedPlaylist(val name: String?, val paths: List) + +typealias PossiblePaths = List class ExternalPlaylistManagerImpl @Inject diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 11ce3dea1..28a391c27 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -75,7 +75,7 @@ interface M3U { class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { val reader = BufferedReader(InputStreamReader(stream)) - val paths = mutableListOf() + val paths = mutableListOf() var name: String? = null consumeFile@ while (true) { @@ -112,39 +112,62 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } // There is basically no formal specification of file paths in M3U, and it differs - // based on the US that generated it. These are the paths though that I assume most - // programs will generate. - val components = + // based on the programs that generated it. These are the paths though that I assume + // most programs will generate. Note that we do end up proposing multiple + // interpretations + val possibilities = when { path.startsWith('/') -> { // Unix absolute path. Note that we still assume this absolute path is in // the same volume as the M3U file. There's no sane way to map the volume // to the phone's volumes, so this is the only thing we can do. - Components.parseUnix(path) + val absoluteInterpretation = Components.parseUnix(path) + val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(absoluteInterpretation, relativeInterpretation) } + path.startsWith("./") -> { // Unix relative path, resolve it - Components.parseUnix(path).absoluteTo(workingDirectory.components) + val absoluteInterpretation = Components.parseUnix(path) + val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(relativeInterpretation, absoluteInterpretation) } + path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> { // Windows absolute path, we should get rid of the volume prefix, but - // otherwise - // the rest should be fine. Again, we have to disregard what the volume - // actually - // is since there's no sane way to map it to the phone's volumes. - Components.parseWindows(path.substring(2)) + // otherwise the rest should be fine. Again, we have to disregard what the + // volume actually is since there's no sane way to map it to the phone's volumes. + val absoluteInterpretation = Components.parseWindows(path.substring(2)) + val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(absoluteInterpretation, relativeInterpretation) } + + path.startsWith("\\") -> { + // Weird unix/windows hybrid absolute path that appears sometimes + val absoluteInterpretation = Components.parseWindows(path) + val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(absoluteInterpretation, relativeInterpretation) + } + path.startsWith(".\\") -> { - // Windows relative path, we need to remove the .\\ prefix - Components.parseWindows(path).absoluteTo(workingDirectory.components) + // Windows-style relative path + val absoluteInterpretation = Components.parseWindows(path) + val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(relativeInterpretation, absoluteInterpretation) } + else -> { - // No clue, parse by all separators and assume it's relative. - Components.parseAny(path).absoluteTo(workingDirectory.components) + // No clue, just go wild and assume all possible combinations. + val unixAbsoluteInterpretation = Components.parseUnix(path) + val unixRelativeInterpretation = unixAbsoluteInterpretation.absoluteTo(workingDirectory.components) + val windowsAbsoluteInterpretation = Components.parseWindows(path) + val windowsRelativeInterpretation = windowsAbsoluteInterpretation.absoluteTo(workingDirectory.components) + listOf(unixRelativeInterpretation, unixAbsoluteInterpretation, + windowsRelativeInterpretation, windowsAbsoluteInterpretation) } } - paths.add(Path(workingDirectory.volume, components)) + paths.add(possibilities.map { Path(workingDirectory.volume, it) }) } return if (paths.isNotEmpty()) { From 248fc89c9bbab039942765110561b0e531a5e45a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 27 May 2024 20:33:48 +0000 Subject: [PATCH 090/110] actions: run on all branches --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 763732ee5..61ec47e0c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,9 +2,9 @@ name: Android CI on: push: - branches: [ "dev" ] + branches: [] pull_request: - branches: [ "dev" ] + branches: [] jobs: build: From 1c74f052221a0a1c3730811e7fc479639807b5b5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 11:57:32 -0600 Subject: [PATCH 091/110] all: fixes/reformat --- .../java/org/oxycblt/auxio/AuxioService.kt | 4 --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 6 ++-- .../org/oxycblt/auxio/music/external/M3U.kt | 36 +++++++++++-------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index fd44f6a5b..64121e6d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -28,8 +28,6 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment -import org.oxycblt.auxio.tasker.indicateServiceRunning -import org.oxycblt.auxio.tasker.indicateServiceStopped @AndroidEntryPoint class AuxioService : MediaLibraryService(), ForegroundListener { @@ -42,7 +40,6 @@ class AuxioService : MediaLibraryService(), ForegroundListener { super.onCreate() mediaSessionFragment.attach(this, this) indexingFragment.attach(this) - indicateServiceRunning() } override fun onBind(intent: Intent?): IBinder? { @@ -73,7 +70,6 @@ class AuxioService : MediaLibraryService(), ForegroundListener { override fun onDestroy() { super.onDestroy() - indicateServiceStopped() indexingFragment.release() mediaSessionFragment.release() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index afcb572bf..99e3fee4d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -164,8 +164,10 @@ constructor( } val deviceLibrary = musicRepository.deviceLibrary ?: return@launch - val songs = importedPlaylist.paths.mapNotNull { - it.firstNotNullOfOrNull(deviceLibrary::findSongByPath) } + val songs = + importedPlaylist.paths.mapNotNull { + it.firstNotNullOfOrNull(deviceLibrary::findSongByPath) + } if (songs.isEmpty()) { logE("No songs found") diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 28a391c27..ddb174d5e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -122,48 +122,54 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte // the same volume as the M3U file. There's no sane way to map the volume // to the phone's volumes, so this is the only thing we can do. val absoluteInterpretation = Components.parseUnix(path) - val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + val relativeInterpretation = + absoluteInterpretation.absoluteTo(workingDirectory.components) listOf(absoluteInterpretation, relativeInterpretation) } - path.startsWith("./") -> { // Unix relative path, resolve it val absoluteInterpretation = Components.parseUnix(path) - val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + val relativeInterpretation = + absoluteInterpretation.absoluteTo(workingDirectory.components) listOf(relativeInterpretation, absoluteInterpretation) } - path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> { // Windows absolute path, we should get rid of the volume prefix, but // otherwise the rest should be fine. Again, we have to disregard what the - // volume actually is since there's no sane way to map it to the phone's volumes. + // volume actually is since there's no sane way to map it to the phone's + // volumes. val absoluteInterpretation = Components.parseWindows(path.substring(2)) - val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + val relativeInterpretation = + absoluteInterpretation.absoluteTo(workingDirectory.components) listOf(absoluteInterpretation, relativeInterpretation) } - path.startsWith("\\") -> { // Weird unix/windows hybrid absolute path that appears sometimes val absoluteInterpretation = Components.parseWindows(path) - val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + val relativeInterpretation = + absoluteInterpretation.absoluteTo(workingDirectory.components) listOf(absoluteInterpretation, relativeInterpretation) } - path.startsWith(".\\") -> { // Windows-style relative path val absoluteInterpretation = Components.parseWindows(path) - val relativeInterpretation = absoluteInterpretation.absoluteTo(workingDirectory.components) + val relativeInterpretation = + absoluteInterpretation.absoluteTo(workingDirectory.components) listOf(relativeInterpretation, absoluteInterpretation) } - else -> { // No clue, just go wild and assume all possible combinations. val unixAbsoluteInterpretation = Components.parseUnix(path) - val unixRelativeInterpretation = unixAbsoluteInterpretation.absoluteTo(workingDirectory.components) + val unixRelativeInterpretation = + unixAbsoluteInterpretation.absoluteTo(workingDirectory.components) val windowsAbsoluteInterpretation = Components.parseWindows(path) - val windowsRelativeInterpretation = windowsAbsoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(unixRelativeInterpretation, unixAbsoluteInterpretation, - windowsRelativeInterpretation, windowsAbsoluteInterpretation) + val windowsRelativeInterpretation = + windowsAbsoluteInterpretation.absoluteTo(workingDirectory.components) + listOf( + unixRelativeInterpretation, + unixAbsoluteInterpretation, + windowsRelativeInterpretation, + windowsAbsoluteInterpretation) } } From c4a3d5290309824043e3c4b4fc7ee235c67e40ec Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 12:19:54 -0600 Subject: [PATCH 092/110] playback: fix skip backward rewind w/enabled New player setup accidentally broke rewind at beginning behavior when rewind before skip is off. Resolves #785 --- CHANGELOG.md | 2 ++ .../oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3907a19ae..c0dafdad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ #### What's Fixed - Fixed repeat mode not restoring on startup +- Fixed rewinding not occuring when skipping back at the beginning of the queue if +rewind before skipping was turned off #### What's Changed - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 8413738b9..2898e4237 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -270,8 +270,10 @@ class ExoPlaybackStateHolder( override fun prev() { if (playbackSettings.rewindWithPrev) { player.seekToPrevious() - } else { + } else if (player.hasPreviousMediaItem()) { player.seekToPreviousMediaItem() + } else { + player.seekTo(0) } if (!playbackSettings.rememberPause) { player.play() From 8b2634df4d4f30e83b46ec0abd1457f6427c922a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 15:06:04 -0600 Subject: [PATCH 093/110] music: handle total absolute m3u paths Some players like generating M3Us with paths starting with /storage/.../..., so I need to handle those too. --- .../org/oxycblt/auxio/music/external/M3U.kt | 163 ++++++++++-------- .../java/org/oxycblt/auxio/music/fs/Fs.kt | 3 + 2 files changed, 98 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index ddb174d5e..908ced355 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -20,18 +20,21 @@ package org.oxycblt.auxio.music.external import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.fs.Components +import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.Volume +import org.oxycblt.auxio.music.fs.VolumeManager +import org.oxycblt.auxio.music.metadata.correctWhitespace +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.unlikelyToBeNull import java.io.BufferedReader import java.io.BufferedWriter import java.io.InputStream import java.io.InputStreamReader import java.io.OutputStream import javax.inject.Inject -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.fs.Components -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.metadata.correctWhitespace -import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.util.logE /** * Minimal M3U file format implementation. @@ -72,8 +75,12 @@ interface M3U { } } -class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U { +class M3UImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val volumeManager: VolumeManager +) : M3U { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { + val volumes = volumeManager.getVolumes() val reader = BufferedReader(InputStreamReader(stream)) val paths = mutableListOf() var name: String? = null @@ -112,68 +119,14 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } // There is basically no formal specification of file paths in M3U, and it differs - // based on the programs that generated it. These are the paths though that I assume - // most programs will generate. Note that we do end up proposing multiple - // interpretations - val possibilities = - when { - path.startsWith('/') -> { - // Unix absolute path. Note that we still assume this absolute path is in - // the same volume as the M3U file. There's no sane way to map the volume - // to the phone's volumes, so this is the only thing we can do. - val absoluteInterpretation = Components.parseUnix(path) - val relativeInterpretation = - absoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(absoluteInterpretation, relativeInterpretation) - } - path.startsWith("./") -> { - // Unix relative path, resolve it - val absoluteInterpretation = Components.parseUnix(path) - val relativeInterpretation = - absoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(relativeInterpretation, absoluteInterpretation) - } - path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> { - // Windows absolute path, we should get rid of the volume prefix, but - // otherwise the rest should be fine. Again, we have to disregard what the - // volume actually is since there's no sane way to map it to the phone's - // volumes. - val absoluteInterpretation = Components.parseWindows(path.substring(2)) - val relativeInterpretation = - absoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(absoluteInterpretation, relativeInterpretation) - } - path.startsWith("\\") -> { - // Weird unix/windows hybrid absolute path that appears sometimes - val absoluteInterpretation = Components.parseWindows(path) - val relativeInterpretation = - absoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(absoluteInterpretation, relativeInterpretation) - } - path.startsWith(".\\") -> { - // Windows-style relative path - val absoluteInterpretation = Components.parseWindows(path) - val relativeInterpretation = - absoluteInterpretation.absoluteTo(workingDirectory.components) - listOf(relativeInterpretation, absoluteInterpretation) - } - else -> { - // No clue, just go wild and assume all possible combinations. - val unixAbsoluteInterpretation = Components.parseUnix(path) - val unixRelativeInterpretation = - unixAbsoluteInterpretation.absoluteTo(workingDirectory.components) - val windowsAbsoluteInterpretation = Components.parseWindows(path) - val windowsRelativeInterpretation = - windowsAbsoluteInterpretation.absoluteTo(workingDirectory.components) - listOf( - unixRelativeInterpretation, - unixAbsoluteInterpretation, - windowsRelativeInterpretation, - windowsAbsoluteInterpretation) - } - } + // based on the programs that generated it. I more or less have to consider any possible + // interpretation as valid. + val interpretations = interpretPath(path) + val possibilities = interpretations.flatMap { + expandInterpretation(it, workingDirectory, volumes) + } - paths.add(possibilities.map { Path(workingDirectory.volume, it) }) + paths.add(possibilities) } return if (paths.isNotEmpty()) { @@ -184,6 +137,80 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } } + private data class InterpretedPath( + val components: Components, + val likelyAbsolute: Boolean + ) + + private fun interpretPath(path: String): List = + when { + path.startsWith('/') -> + listOf(InterpretedPath(Components.parseUnix(path), true)) + + path.startsWith("./") -> listOf( + InterpretedPath( + Components.parseUnix(path), + false + ) + ) + + path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> listOf( + InterpretedPath( + Components.parseWindows( + path.substring(2) + ), true + ) + ) + + path.startsWith("\\") -> listOf( + InterpretedPath( + Components.parseWindows(path), + true + ) + ) + + path.startsWith(".\\") -> listOf( + InterpretedPath( + Components.parseWindows(path), + false + ) + ) + + else -> listOf( + InterpretedPath(Components.parseUnix(path), false), + InterpretedPath(Components.parseWindows(path), true) + ) + } + + private fun expandInterpretation( + path: InterpretedPath, + workingDirectory: Path, + volumes: List + ): List { + val absoluteInterpretation = Path(workingDirectory.volume, path.components) + val relativeInterpretation = + Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components)) + val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true } + val volumeInterpretation = volumeExactMatch?.let { + val components = unlikelyToBeNull(volumeExactMatch.components) + .containing(path.components) + Path(volumeExactMatch, components) + } + return if (path.likelyAbsolute) { + listOfNotNull( + volumeInterpretation, + absoluteInterpretation, + relativeInterpretation + ) + } else { + listOfNotNull( + relativeInterpretation, + volumeInterpretation, + absoluteInterpretation + ) + } + } + override fun write( playlist: Playlist, outputStream: OutputStream, diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 2639ec207..00f486a3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -158,6 +158,9 @@ value class Components private constructor(val components: List) { return components == other.components.take(components.size) } + fun containing(other: Components) = + Components(components + other.components.drop(components.size)) + companion object { /** * Parses a path string into a [Components] instance by the unix path separator (/). From d906b87d766d6c2acfacbdf5153a0ec12be2ac0c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 19:20:18 -0600 Subject: [PATCH 094/110] all: reformat --- .../org/oxycblt/auxio/music/external/M3U.kt | 95 ++++++------------- 1 file changed, 30 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 908ced355..211f6cea6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -20,6 +20,12 @@ package org.oxycblt.auxio.music.external import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import javax.inject.Inject import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Path @@ -29,12 +35,6 @@ import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.unlikelyToBeNull -import java.io.BufferedReader -import java.io.BufferedWriter -import java.io.InputStream -import java.io.InputStreamReader -import java.io.OutputStream -import javax.inject.Inject /** * Minimal M3U file format implementation. @@ -75,7 +75,9 @@ interface M3U { } } -class M3UImpl @Inject constructor( +class M3UImpl +@Inject +constructor( @ApplicationContext private val context: Context, private val volumeManager: VolumeManager ) : M3U { @@ -122,9 +124,8 @@ class M3UImpl @Inject constructor( // based on the programs that generated it. I more or less have to consider any possible // interpretation as valid. val interpretations = interpretPath(path) - val possibilities = interpretations.flatMap { - expandInterpretation(it, workingDirectory, volumes) - } + val possibilities = + interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) } paths.add(possibilities) } @@ -137,49 +138,20 @@ class M3UImpl @Inject constructor( } } - private data class InterpretedPath( - val components: Components, - val likelyAbsolute: Boolean - ) + private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean) private fun interpretPath(path: String): List = when { - path.startsWith('/') -> - listOf(InterpretedPath(Components.parseUnix(path), true)) - - path.startsWith("./") -> listOf( - InterpretedPath( - Components.parseUnix(path), - false - ) - ) - - path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> listOf( - InterpretedPath( - Components.parseWindows( - path.substring(2) - ), true - ) - ) - - path.startsWith("\\") -> listOf( - InterpretedPath( - Components.parseWindows(path), - true - ) - ) - - path.startsWith(".\\") -> listOf( - InterpretedPath( - Components.parseWindows(path), - false - ) - ) - - else -> listOf( - InterpretedPath(Components.parseUnix(path), false), - InterpretedPath(Components.parseWindows(path), true) - ) + path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true)) + path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false)) + path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> + listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true)) + path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true)) + path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false)) + else -> + listOf( + InterpretedPath(Components.parseUnix(path), false), + InterpretedPath(Components.parseWindows(path), true)) } private fun expandInterpretation( @@ -191,23 +163,16 @@ class M3UImpl @Inject constructor( val relativeInterpretation = Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components)) val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true } - val volumeInterpretation = volumeExactMatch?.let { - val components = unlikelyToBeNull(volumeExactMatch.components) - .containing(path.components) - Path(volumeExactMatch, components) - } + val volumeInterpretation = + volumeExactMatch?.let { + val components = + unlikelyToBeNull(volumeExactMatch.components).containing(path.components) + Path(volumeExactMatch, components) + } return if (path.likelyAbsolute) { - listOfNotNull( - volumeInterpretation, - absoluteInterpretation, - relativeInterpretation - ) + listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation) } else { - listOfNotNull( - relativeInterpretation, - volumeInterpretation, - absoluteInterpretation - ) + listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation) } } From 643defd9e4c17f6edbfb1cb8fb9b5816fb3e6895 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 19:21:06 -0600 Subject: [PATCH 095/110] playback: fix play song by itself Accidental misup led to it playing from all songs instead --- .../java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt index 82685f051..f53745632 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt @@ -91,7 +91,8 @@ constructor( override val shuffled: Boolean ) : PlaybackCommand - override fun song(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) + override fun song(song: Song, shuffle: ShuffleMode) = + newCommand(song, null, listOf(song), shuffle) override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) @@ -105,7 +106,7 @@ constructor( newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle) override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) = - newCommand(song, playlist, playlist.songs, listSettings.playlistSort, shuffle) + newCommand(song, playlist, playlist.songs, shuffle) override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle) From 111cb9688f59bca20d0a7b0255d6a72f48b51fad Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 21:44:15 -0600 Subject: [PATCH 096/110] tasker: completely remove --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index fd44f6a5b..64121e6d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -28,8 +28,6 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.IndexerServiceFragment import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment -import org.oxycblt.auxio.tasker.indicateServiceRunning -import org.oxycblt.auxio.tasker.indicateServiceStopped @AndroidEntryPoint class AuxioService : MediaLibraryService(), ForegroundListener { @@ -42,7 +40,6 @@ class AuxioService : MediaLibraryService(), ForegroundListener { super.onCreate() mediaSessionFragment.attach(this, this) indexingFragment.attach(this) - indicateServiceRunning() } override fun onBind(intent: Intent?): IBinder? { @@ -73,7 +70,6 @@ class AuxioService : MediaLibraryService(), ForegroundListener { override fun onDestroy() { super.onDestroy() - indicateServiceStopped() indexingFragment.release() mediaSessionFragment.release() } From b0703b4d0e3d10e9a723d0ea77e00fd89580f41d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 8 Jun 2024 21:44:48 -0600 Subject: [PATCH 097/110] playback: fix widget not resetting on service end --- .../playback/service/PlaybackActionHandler.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 8597770de..6d0c1fbeb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -49,13 +49,15 @@ constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val systemReceiver: SystemPlaybackReceiver + private val widgetComponent: WidgetComponent ) : PlaybackStateManager.Listener, PlaybackSettings.Listener { interface Callback { fun onCustomLayoutChanged(layout: List) } + private val systemReceiver = + SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) private var callback: Callback? = null fun attach(callback: Callback) { @@ -71,6 +73,7 @@ constructor( playbackManager.removeListener(this) playbackSettings.unregisterListener(this) context.unregisterReceiver(systemReceiver) + widgetComponent.release() } fun withCommands(commands: SessionCommands) = @@ -180,12 +183,10 @@ object PlaybackActions { * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver -@Inject -constructor( - val playbackManager: PlaybackStateManager, - val playbackSettings: PlaybackSettings, - val widgetComponent: WidgetComponent +class SystemPlaybackReceiver( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false From d117f160810510a50c052b71c0a3ac27ff9ff13d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 13:13:54 -0600 Subject: [PATCH 098/110] image: prefer exoplayer over aosp covers Will actually handle files with multiple covers. Could lead to more performance concerns, but that's also the same with AOSP too. --- .../java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index d28979b6b..f1be38db3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -157,8 +157,8 @@ constructor( } private suspend fun extractQualityCover(cover: Cover.Embedded) = - extractAospMetadataCover(cover) - ?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover) + extractExoplayerCover(cover) + ?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover) private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? = MediaMetadataRetriever().run { From dbe7bdf1c3e19f0c48ae8f099d93c0bade55691f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 16:50:04 -0600 Subject: [PATCH 099/110] music: fix m3u volume processing --- app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 00f486a3c..9f3cbff3b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -158,8 +158,7 @@ value class Components private constructor(val components: List) { return components == other.components.take(components.size) } - fun containing(other: Components) = - Components(components + other.components.drop(components.size)) + fun containing(other: Components) = Components(other.components.drop(components.size)) companion object { /** From cff700231e04e25745e77dd0bcc86207f5b6ac7c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 16:52:16 -0600 Subject: [PATCH 100/110] playback: fix android auto queue crash --- .../auxio/playback/service/MediaSessionPlayer.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 6b56d334a..493969467 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -173,6 +173,14 @@ class MediaSessionPlayer( playbackManager.repeatMode(appRepeatMode) } + override fun seekToDefaultPosition() { + playbackManager.seekTo(0) + } + + override fun seekToDefaultPosition(mediaItemIndex: Int) { + playbackManager.goto(mediaItemIndex) + } + override fun seekToNext() = playbackManager.next() override fun seekToNextMediaItem() = playbackManager.next() @@ -278,10 +286,6 @@ class MediaSessionPlayer( override fun setPlaybackSpeed(speed: Float) = notAllowed() - override fun seekToDefaultPosition() = notAllowed() - - override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed() - override fun seekForward() = notAllowed() override fun seekBack() = notAllowed() From a9e7ae398c0b5cd512d35bc1e9a298ff50f25bf9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 16:52:27 -0600 Subject: [PATCH 101/110] playback: fix service memory leak --- .../auxio/playback/service/MediaSessionServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index fb884cee3..69f2aab6d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -129,7 +129,7 @@ constructor( fun release() { waitJob.cancel() - mediaSession.release() + mediaItemBrowser.release() actionHandler.release() exoHolder.release() playbackManager.removeListener(this) From 4f71dba90e02f446e4cae2c4f18922d58fef702b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 19:40:42 -0600 Subject: [PATCH 102/110] playback: fix various android auto issues - Broken queue - Unusable item details --- .../auxio/music/service/MediaItemBrowser.kt | 28 +++++++++++++++---- .../music/service/MediaItemTranslation.kt | 8 +++--- .../playback/service/MediaSessionPlayer.kt | 26 ++++++----------- media | 2 +- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 63b68925d..0ca607167 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -19,6 +19,9 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants import androidx.media3.common.MediaItem import androidx.media3.session.MediaSession.ControllerInfo import dagger.hilt.android.qualifiers.ApplicationContext @@ -29,6 +32,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album @@ -213,27 +217,41 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item) } + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } + albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context) } + - songs.map { it.toMediaItem(context, null) } + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + + songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } } is Playlist -> { - item.songs.map { it.toMediaItem(context, item) } + item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Song, null -> return null } } + private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { + val oldExtras = mediaMetadata.extras ?: Bundle() + val newExtras = + Bundle(oldExtras).apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(res)) + } + return buildUpon() + .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) + .build() + } + private fun getCategorySize( category: MediaSessionUID.Category, musicRepository: MusicRepository diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index bde25c1b5..13fdb581e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -103,7 +103,7 @@ fun Album.toMediaItem(context: Context): MediaItem { .setReleaseMonth(dates?.min?.month) .setReleaseDay(dates?.min?.day) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) @@ -133,7 +133,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) })) .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setGenre(genres.resolveNames(context)) .setArtworkUri(cover.single.mediaStoreCoverUri) @@ -157,7 +157,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) }) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) @@ -180,7 +180,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) }) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover?.single?.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 493969467..f5ea4215c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -173,12 +173,13 @@ class MediaSessionPlayer( playbackManager.repeatMode(appRepeatMode) } - override fun seekToDefaultPosition() { - playbackManager.seekTo(0) - } - override fun seekToDefaultPosition(mediaItemIndex: Int) { - playbackManager.goto(mediaItemIndex) + val indices = unscrambleQueueIndices() + val fakeIndex = indices.indexOf(mediaItemIndex) + if (fakeIndex < 0) { + return + } + playbackManager.goto(fakeIndex) } override fun seekToNext() = playbackManager.next() @@ -191,18 +192,9 @@ class MediaSessionPlayer( override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - override fun seekTo(mediaItemIndex: Int, positionMs: Long) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - if (positionMs == C.TIME_UNSET) { - return - } - playbackManager.seekTo(positionMs) - } + override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() + + override fun seekToDefaultPosition() = notAllowed() override fun addMediaItems(index: Int, mediaItems: MutableList) { val deviceLibrary = musicRepository.deviceLibrary ?: return diff --git a/media b/media index 00124cbac..9fc2401b8 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 00124cbac493c06a24e19b01893946bdaf8faf58 +Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f From ba0d2cd879de6b55e230800616d1fda93e34d365 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 9 Jun 2024 20:25:33 -0600 Subject: [PATCH 103/110] playback: add tab icons --- .../music/service/MediaItemTranslation.kt | 62 +++++++++++++++--- .../res/drawable-hdpi/ic_album_bitmap_24.png | Bin 0 -> 676 bytes .../res/drawable-hdpi/ic_artist_bitmap_24.png | Bin 0 -> 531 bytes .../res/drawable-hdpi/ic_genre_bitmap_24.png | Bin 0 -> 432 bytes .../drawable-hdpi/ic_playlist_bitmap_24.png | Bin 0 -> 259 bytes .../res/drawable-hdpi/ic_song_bitmap_24.png | Bin 0 -> 291 bytes .../res/drawable-mdpi/ic_album_bitmap_24.png | Bin 0 -> 375 bytes .../res/drawable-mdpi/ic_artist_bitmap_24.png | Bin 0 -> 313 bytes .../res/drawable-mdpi/ic_genre_bitmap_24.png | Bin 0 -> 241 bytes .../drawable-mdpi/ic_playlist_bitmap_24.png | Bin 0 -> 182 bytes .../res/drawable-mdpi/ic_song_bitmap_24.png | Bin 0 -> 175 bytes .../res/drawable-xhdpi/ic_album_bitmap_24.png | Bin 0 -> 894 bytes .../drawable-xhdpi/ic_artist_bitmap_24.png | Bin 0 -> 642 bytes .../res/drawable-xhdpi/ic_genre_bitmap_24.png | Bin 0 -> 422 bytes .../drawable-xhdpi/ic_playlist_bitmap_24.png | Bin 0 -> 328 bytes .../res/drawable-xhdpi/ic_song_bitmap_24.png | Bin 0 -> 326 bytes .../drawable-xxhdpi/ic_album_bitmap_24.png | Bin 0 -> 1644 bytes .../drawable-xxhdpi/ic_artist_bitmap_24.png | Bin 0 -> 1092 bytes .../drawable-xxhdpi/ic_genre_bitmap_24.png | Bin 0 -> 812 bytes .../drawable-xxhdpi/ic_playlist_bitmap_24.png | Bin 0 -> 559 bytes .../res/drawable-xxhdpi/ic_song_bitmap_24.png | Bin 0 -> 560 bytes .../drawable-xxxhdpi/ic_album_bitmap_24.png | Bin 0 -> 1774 bytes .../drawable-xxxhdpi/ic_artist_bitmap_24.png | Bin 0 -> 1150 bytes .../drawable-xxxhdpi/ic_genre_bitmap_24.png | Bin 0 -> 784 bytes .../ic_playlist_bitmap_24.png | Bin 0 -> 672 bytes .../drawable-xxxhdpi/ic_song_bitmap_24.png | Bin 0 -> 687 bytes 26 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png create mode 100644 app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png create mode 100644 app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png create mode 100644 app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png create mode 100644 app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 13fdb581e..9a5bb53c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -19,10 +19,15 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Bundle +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,14 +42,27 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { + // TODO: Make custom overflow menu for compat + val style = + Bundle().apply { + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) + } val metadata = MediaMetadata.Builder() .setTitle(context.getString(nameRes)) .setIsPlayable(false) .setIsBrowsable(true) .setMediaType(mediaType) - .build() - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() + .setExtras(style) + if (bitmapRes != null) { + val data = ByteArrayOutputStream() + BitmapFactory.decodeResource(context.resources, bitmapRes) + .compress(Bitmap.CompressFormat.PNG, 100, data) + metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON) + } + return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build() } fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { @@ -205,14 +223,38 @@ fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { } sealed interface MediaSessionUID { - enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : - MediaSessionUID { - ROOT("root", R.string.info_app_name, null), - SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); + enum class Category( + val id: String, + @StringRes val nameRes: Int, + @DrawableRes val bitmapRes: Int?, + val mediaType: Int? + ) : MediaSessionUID { + ROOT("root", R.string.info_app_name, null, null), + SONGS( + "songs", + R.string.lbl_songs, + R.drawable.ic_song_bitmap_24, + MediaMetadata.MEDIA_TYPE_MUSIC), + ALBUMS( + "albums", + R.string.lbl_albums, + R.drawable.ic_album_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + ARTISTS( + "artists", + R.string.lbl_artists, + R.drawable.ic_artist_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + GENRES( + "genres", + R.string.lbl_genres, + R.drawable.ic_genre_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), + PLAYLISTS( + "playlists", + R.string.lbl_playlists, + R.drawable.ic_playlist_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); override fun toString() = "$ID_CATEGORY:$id" diff --git a/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..c25d1465bb0b14a71d84918535366c314adb091f GIT binary patch literal 676 zcmV;V0$crwP)<;T&hfF=cgFUen|sDnhnX#jJK{&NC;kzyGimeeAZwOl|EWRduf#p^ zcOLxT5TZfbrEm1R6J+>S{8brrR9=X?(RXafWKsMa%=zbEEUng>xFRlO(&ia4G;t+) zm5Xtlkw8cBNc@}4`28wwi7%o)0!`L_4<2}qKPh$k6pUD@mr*wi^vQj_*Fn^^!cxwZ zWyav0xDa)tg1*_KObU*cQK#C*@a)L?2##61{i-f}9!nniN|X_r+uJ zB)8Y2t^hAfIaU0-QF9BHHa+$Z#8k*u)BrtyEuJ~!$Wt`2AJFsd?2V4G25+7UcAS{9 zZB!Eji9g)ngNUXvDLlpVtYNH9)bwHLI4FjDF%GPKWg<^wf1u|doMv#vSvrS`aIF&G zs0=!c8x@~7F5ZjrJ`}uk!j(##q>Te!iHDiAV|>5j!;W6!moeUl-QFh}rEaN`;($zK zK9hEgA6IXeTjd!?`Q}J+Z;A>#UeF3` zmaeeC>X&@pKS|lBz6dl~OCRqs*(D$3NwHF8nPL14TDF_dAlf{mFevTmeFk}0%Bhm* zlW;m8iXN7#uWPgLDeYlt^Xyn!{+^`+nZY4#o)JUSzUe*VDd0crP+S}ed4}x(0000< KMNUMnLSTaB3_*4P literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..7a4ec1d2444c353938c1df8d8fd96a30e3c93510 GIT binary patch literal 531 zcmV+u0_^>XP)Ap1wn$nop0#@enVFltyPL@b1qB5K{hvf+92|jnh2u%r*Kna}3z!Ba@a@34 zS$f>Oh6&Ajz?0`;49}p~&1;y@Y{qJS1^N|^&3wVlt637giac0W1~}*OtyzsXtoaRV ze$~#=c&hrb5FC?exlPd*FyZmpD|9J;E5vct6-rw_mQyfp=<#t6J$Sr^M|wPYsp`i< z@Y9p<;qh_lr=00oLh#&^P;`Aph>d~+@T%}08h3q0wbp0Ckdz;1zVBhz zSFh@-FVW^)1WOW~h_a=}?@llR*1;|~0k_I2*wYwSFK6d!eq#O9LSj~;eRB=Iq;lz9 z=(&+-KV{u|i9$jS%z;C2Cx4r7UE{4_(`i@>JD^Q#t>_$kuoru>cb9pXJUElef6dd9 zSI@i_FtK}yniRkw=uo#FTL~oF7S|n|3W9y=5)m9LCizI>;Z6X4*cbd+0=fW`k zf3^7MVsH<9D5K*!-s7*NQ=OQYvNpk@jcDHiT&owbqaFa63Z#8k4MY~(M=x;S$ zQo@tTHGLE1no1;LW}_w+jN0I}AVefhO;IqLr|?(e%>+qTQ;}eNU_$xUgcn}|r{Eq; zN$K;T%T`E5@sat+`{v(P!#IhbPdCza56TCre}9t1;ep2KLSm;jkB@caTA3a z_=<_h8RF4!XV4V{%W atNH}xJ-DD@A%!#m0000-OL+cZHC1c40^ z4e+^uL+zUB{u!n44{o%O2cpoY=xS`MkONZw002ov JPDHLkV1oK>a#8>Q literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb67934bbdc24017eef30d7e418c90402bb32a6 GIT binary patch literal 291 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBUU|AWhE&A8o$AQfWFX-BkSXR! z1@mLK{6=kYIhTBwp2D}2DwcmV-*my>TXE(_y=SWWic{v>D{cvy`uGp0QqifClEDE# z&H%|BHfrfU4?oSAW7v8+x#*aPUO!N9Ma`DOO>+#F-k9z2kooC{#-bk%mR-CphuHES zFv>kF+wuK&V?fAZ**$gB_{&t;vJ&Mb(^S&0I-ZoW+hL*l9LQ+(UjAf{mB!BEms941 zyQkGzH`_kHIx#i>#j(oLl$+)y&-Pdu)lY9t51V7|`005-M7Cvm+aCW{dzbjX+7MQJ mPUh#o>8yuK51+U6(~O(nzJ0d< literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..1047e230c8d87418588c3dc9a40b29eaf53be049 GIT binary patch literal 375 zcmV--0f_#IP)o5{OPbK*3XB`zA- zmJ8#lY6sI=Ks>2AavJOH7kg29ZRPU3!qkqYwQw6eI_<6-WG_+nL(YCp^L}Uqj8a3t zX%(;0??8S!=`GD}8~p^5w(DA&!{ZIHcFi$0dX{4s9&XnLY07o~AC8CQ{AK<0SpXGNuEXV0R9GIV$RqTC^IIPqL7kzI@WgA$+fu@GPV(7hLP1LVc$Tvjb#$*K$Ql00000 LNkvXXu0mjf1^0(S literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..d5034c0d851153d009c18af7c3951cd2396ac345 GIT binary patch literal 241 zcmVgpJ(l z9NmwK6U9GY2QBShFo@k0T}U1`b}tBorV8FQ=bBr)>kT3^cu6coG(0l`~Np1cYE0l|}pK*0YX;whjcEO^K%|Ecnd z*PT3Sy4UI1+2G0+6b#e#>b;%m&)JogC6-uXW&l_m&SF?GZ$E*edDB1$gKQf+}C`g z0p8Ew6cT5$pC7Xb;}*gb<#oEpHfrMdQ1WezJ3h>C&G?~^jJ!1NwIroB#V}U=NakA8 zxIswCO2to%&sEd&0LA4(Nw^15JtWqhjIp`cgt)I7(kZ34W{s_AEp^?C#7 znY=aMuz?L9coQ+>QyRVajPxV)O>lNq_tyRu`#W1J-oA z@aguO@h#jTaJE;u*CXuQ1IQi91qF5%6hK z_cnW~i?H?`_|w*6YovBZeIl4sVp!At(<*$Y7z4R#Rs-QrzNPp~#%!uz2K+&C0LPeZ z)pY>h633va0|@=PO^s*j7XjaPME#*^Ixx|x1Xbr@_<+@-T3H>~*7i)ar>f}y-FbLR zH5t}+QYfgEC+eF4-vCDKm1;Ubx8GG45o^0H6qNZMJ{^<1*7nS6_051UrcryNnhrF9 zwnw15Rz&`tw|QM?&%BGC3e{_S2Q2}3pg-WJ1U*qr2BvqRclg(Zc&+W3EnVnVH67p) z4B~~KMq3xM+OeKhT`O zr`UX;;Wh772W$jpJsb U?VH8hAOHXW07*qoM6N<$f)wev00000 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..880afde6446edbeb9bb3cf9dba96e7f93f5fbaff GIT binary patch literal 642 zcmV-|0)737P)Nkl`w&uZ5GGfBb=+*j~Js=HQ4 zQRZ~p(bo`~FM(t5FEe0Qn0qQ6zLicwp-pfi;r;!OYQ$fLv|AE>Z3;8Y5Ppd|A`c0FYR}^TrQW( c<@(?B1nD=xwcIFRtpET307*qoM6N<$f`^$fY5)KL literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..7ac11d34d6934ddc3f5e3b0c9aa14cddbabccbdd GIT binary patch literal 422 zcmV;X0a^ZuP)7oV%ECtwtn7S*fDfQxqmaVZTCfsKX+Ve=6g$Bxg&^de)_=HJS%3C& z7Yk>E`QV3zxmmv1+k=q7AP9mWP*O@m z1wG{@3+^>%4SC6g`%PI>{temYJYbr6(Mg$$Xf!T>xyVC{^W(! z?+y5r&vXpoTRzh=guOfjHj!iG9$9E)J(3zdTMpquUW8^UREyMxm7a+Qc{%x2^5qae z-h>NMb8T3*EhFwX2U1g#yh+XVkjT2MO}XD3NPUpx_g&W^%9^jgKOnS4g3FG&y8-q| z@R90=RG*i1Q}lEnNbr*C&r~0mdzheNN60J5N%piZC(rVq$S&{q=Ro43BHkzVr=Nk$ zSw(PFZjlY%$CT83z%LcqGIDLk?&LEfh8!SQ$Xuo6N1Uuh-2To7K@gbo1^MOZyy)kd QMgRZ+07*qoM6N<$g3)Znx&QzG literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..d88b412c9306a4e85140534b33c784c92ab6ccfe GIT binary patch literal 328 zcmV-O0k{5%P)i0dFUGHo>m+7+tvXPH~3n<~3Fngb+fA87ZaL zFSWNHeMM-jt-bx|D?(#!?d?Zj5gKc2Z$J9F;co^lp#@q~eE|m6)>=Q-!!WS6*7~s? zhJm%U){pft46LoSeyn$o7HEwYAPkI(d0(t{G+j(L)A%`m-Rj>A1EXS|W7kinJJ04s zZx{wfmC5rvziIfKKMVt-%H;WDGdouj{mrqV1`fF-`kP}zJvro(=xoThie|4O+0vtR4;&I=)g a=$1E>9tr8wnhKc!0000@>|@4Ay3*;8{5Ey=SFTfIo5YyYt`>vR+M%5bF-BIZ0~ zHO^u%HEozB<>1S^U>RG0@gD}gtsmdmxS!7y>wO&2egBxufz)gBbXg~IU%%mgNT+_m z9rZ+g&S~*KwYX3|0ZvWKwSLH^zzR};S^$Bn13l+YbRlDSjbIO00oY@xXIzQ9j)cLoh*vx%Y mY$bGjquH@}`dy2DNScQ{Xs~iG)BOev9|liXKbLh*2~7a{UyOwS literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..68d136f59d9d79d1064349c3a04152ec6063b65d GIT binary patch literal 1644 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 QHUIzs07*qoM6N<$f|cG__5c6? literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..e515a4134a20a006477d86a68f6a4726f0e14203 GIT binary patch literal 1150 zcmV-^1cCdBP)o8+5_Zn9AM1_V2*ODkYiIT+?3`o&P26U6gdnbhp0T?Z zW5=%8z7|fHqmvE*4jg|L#Nd8%K#sO%%k4|SbRECpHVHfD*pJm*f^ekxU53uzoX&DG z+op@a^h4NY+m3HLHk@Pov6>#jgYTon?}B)HaVJySvY!%AwhXmIUn3^QWPeX3-_*1W z#S!D*0kP(>^HNpUL@ew@3`dTChu~!6I4;$O{gp7Y&zv^gPYKq}@l~n~`>O_Jw{}kN z?5_mG&A3m;-yAh<#^D6JzI`ALguN%&%i8$G$o{rtcCL4yr;i_Pvta({FN8(JkIGkZZohUG5kJaig4y)j z2U!q{h##>gS|?nC?IAh;)q6zncZs;qW8VjD5x;oj&k~c;=ZQ(y<~Hb({mubLjQ{8i z!CB&JZue4|PuouJ^I-f}K-|r=k7~VHeJEL9HqeJF6>DHf!iSTfW9b9`<3J#mnnRSC zR@oh~%=(S5A3Vkn!d$tq<9mPfK%f1cL+PHccMRfBp1FyW76DttTwP<%orSo9FlXG+ zl(Jg@Sa$N0vR_jpPR38C%|2~|ZrN8B#8J-8@F`{A{;-bZCuQ$qw8vpN%?||P=sr6Q zYvj&;#G3!%*q11+v2CBnejd!$>U4gvwKlnOQ6hJ_ikK(1t@nDtEJ+3ASt0TQ5h4zR;72<4LextNJ<0 zz;8l*DCfagb+dqA%qY@2qAV7l(<;uum9_jc~Zz{dt6u9q2j za~Mq=m@^xg7BEQNU<#1-W)#^F?Xa!k?aOs>Zw#I(MfW8y=AUm|-0rg`-T1lcwtxdN zd_dzMpup{t^SSkNg8zR#dHjG|%FTYu|6G+{H*PQz{*-y>+L;Od!rLBk{WM9;6+5w< zGrO?4W>$gruGoL7=lA8WSn&0U`@P!#294LI*7wOXyIo-WRDAD`MS#QY6a6+nHXgW8 z_No3Cn3^ol&7-(q)~U|AaKWjcI%e1IitJLk#o z{Kglod_DHX?_j!nt>ww@W>%dXr%!(m@W$M1dGd7wlkw~cd-ATQD1Nu}fAwzT2Ri}L z{`22sPyBpXnw+tV^W^dO(gVP3%W zn%{YbhTg@#!jtUxM^`E86=$ZIGFDxg``Mp$qIdD7Guccs=XgC>JGZO-EL_mAYvr=j z(b{{svr;_Ivlh;?w>&;)x7|mJd7lgA^6CPWy{oi4ihq{3?#ggk%lYSJeXq{Ta>qLc z?(267+~#`R{_vf$Dc2>N__)8~NzsXamRImf=$(1|SXTc1$BzaM#%T_5#wl*L%NJ;d z{C`>gNomzDo~%uE-&aHjm1Q3Gxp&C*D|f{wsTYB@Y{HVUmjnM^mA9;tm9aZ_cX`u> z#D7^SY*()T{-OWT%3{vby*pM+R9Vk^rIzbivbSg4?&vFflbrvoRLIi#9wz@N`}rU5 zM|Tff1uV!7v&ydg_&)jii|W}2?(A>bx1956tM!YEf4DzPYWdUunyqKSyZTjErcGS$ z`)8iO`ajtL-}5?dcAeg%7I>buT=Uhm$;q}EKce%Cg|CY&o0TnSbX8ZsCF1`S9VwCX iN4G7$&IC(_ZyNubZ8L4{eZjL9B;@Jp=d#Wzp$PyuD0O52 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..b0a08a8b3387b5aff03fcf1c7cb93a9c0baf5c83 GIT binary patch literal 672 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>U`qFNaSW-5dwc7l?-WM?wuDy; z7*AJwM(&9bSV$y?U_ed5>EAOED9-OwdUVMQ~y z$u)OF@w_$tNqTo8*wd;Z&Wo6U2XOOIFHOSUVw{(N<_ zUS~Sc0q-o%M?W(zH$FeFIMZf7Si!&4In(|>>X~|DF_;@?yl%rEl^nO)w{d5oVuZJF#XGmOIpeM z55!z5pB^Upn>8k)^o@8y;?<4u33@Nvy|vFw{>$`um;RytZ`?hv|4CHk??15Po%F`& z-$x}MTgkiH+)$4*^pijK*Jkq(@8UUA|NoUY%(Cy>_xVpndvam=w4WDKerorz_MP4* zD|gTe~DWM4fpGH!F literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..c0869bc1d56a02d2e443c9a52b324b9bdb13e69a GIT binary patch literal 687 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>U@G%;aSW-5dwZ+VSK3j;HPCzl zn?{4#Bc>qs+#|UyOd1NB4I&ff)unCX(7n#a`ll=`$#dF0t;Tc0CAib6tMIyZ zRAhYAzHK%6KQDf2F#4VRcV~WWS?Qg~m#^1czreb7$?WAx=TB8D_xm6JlKk^dcY*EN zEluwtZdiY=c(_sg;Vdhg`FRyxH&tS)tJY4rJ#D^D*6M=4E3bZAfqH}=ZATsK-T$^C{FijMn*tJ*73{! z{NIh|h8?o?mXv&#Ze{wTSN!l_pZ3-szVF7eVn$YA)%zn(9k3~|zr3$yqEBfDjxkL~;Z-7Pv?>$CRQKj&wCuU~(7Ki%L_ z{G@xo_b;2q{b^eMnP1uV_1S*nS3Yf>qFcxL*EE0UJiQfv#5e!S{yF`BT Date: Wed, 12 Jun 2024 19:12:20 -0600 Subject: [PATCH 104/110] music: disable timeouts This isn't working right now due to how LONG it takes to actually load images. --- .../java/org/oxycblt/auxio/util/StateUtil.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index d723dd5e3..71927c70c 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import org.oxycblt.auxio.BuildConfig /** * A wrapper around [StateFlow] exposing a one-time consumable event. @@ -166,7 +167,13 @@ suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = DEFAU try { withTimeout(timeout) { send(element) } } catch (e: TimeoutCancellationException) { - throw TimeoutException("Timed out sending element $element to channel: $e") + logE("Failed to send element to channel $e in ${timeout}ms.") + if (BuildConfig.DEBUG) { + throw TimeoutException("Timed out sending element to channel: $e") + } else { + logE(e.stackTraceToString()) + send(element) + } } } @@ -203,7 +210,13 @@ suspend fun ReceiveChannel.forEachWithTimeout( subsequent = true } } catch (e: TimeoutCancellationException) { - throw TimeoutException("Timed out receiving element from channel: $e") + logE("Failed to send element to channel $e in ${timeout}ms.") + if (BuildConfig.DEBUG) { + throw TimeoutException("Timed out sending element to channel: $e") + } else { + logE(e.stackTraceToString()) + handler() + } } } } From 96d4a84f52d10fcd137e8eecf581fb32b8e8e9d8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 12 Jun 2024 20:32:32 -0600 Subject: [PATCH 105/110] playback: fix parent restore A single missed savedState access blew up parent restore silently, and in some other cases with non-destructive queue restores would also not restore the parent. --- .../service/ExoPlaybackStateHolder.kt | 31 +++++++++++++------ .../playback/state/PlaybackStateManager.kt | 11 ++----- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 2898e4237..058b33403 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -367,17 +367,28 @@ class ExoPlaybackStateHolder( rawQueue: RawQueue, ack: StateAck.NewPlayback? ) { - this.parent = parent - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) - if (rawQueue.isShuffled) { - player.shuffleModeEnabled = true - player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - player.shuffleModeEnabled = false + logD("Applying saved state") + var sendEvent = false + if (this.parent != parent) { + this.parent = parent + sendEvent = true + } + if (rawQueue != resolveQueue()) { + player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) + player.prepare() + player.pause() + sendEvent = true + } + if (sendEvent) { + ack?.let { playbackManager.ack(this, it) } } - player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) - player.prepare() - ack?.let { playbackManager.ack(this, it) } } override fun endSession() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 347b099ca..494ab2c0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -795,15 +795,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { index }) - // Valid state where something needs to be played, direct the stateholder to apply - // this new state. - val oldStateMirror = stateMirror - if (oldStateMirror.rawQueue != rawQueue) { - logD("Queue changed, must reload player") - stateHolder.playing(false) - stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback) - stateHolder.seekTo(savedState.positionMs) - } + stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback) + stateHolder.seekTo(savedState.positionMs) stateHolder.repeatMode(savedState.repeatMode) isInitialized = true From 5861d1db87cb70aeaade498511d8c720d25c3f42 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 13 Jun 2024 19:49:53 -0600 Subject: [PATCH 106/110] music: use both ogg/mp3 style mb tags at once Apparently both can exist on both types of files, and grouping will break as a result due to MBID mismatch. --- .../oxycblt/auxio/music/metadata/TagWorker.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 202f364df..fe8ca0c46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -140,7 +140,9 @@ private class TagWorkerImpl( private fun populateWithId3v2(textFrames: Map>) { // Song - textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() } + (textFrames["TXXX:musicbrainz release track id"] + ?: textFrames["TXXX:musicbrainz_releasetrackid"]) + ?.let { rawSong.musicBrainzId = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() } @@ -170,7 +172,9 @@ private class TagWorkerImpl( ?.let { rawSong.date = it } // Album - textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() } + (textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let { + rawSong.albumMusicBrainzId = it.first() + } textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] @@ -180,7 +184,9 @@ private class TagWorkerImpl( ?.let { rawSong.releaseTypes = it } // Artist - textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } + (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { + rawSong.artistMusicBrainzIds = it + } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } (textFrames["TXXX:artistssort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] @@ -188,9 +194,9 @@ private class TagWorkerImpl( ?.let { rawSong.artistSortNames = it } // Album artist - textFrames["TXXX:musicbrainz album artist id"]?.let { - rawSong.albumArtistMusicBrainzIds = it - } + (textFrames["TXXX:musicbrainz album artist id"] + ?: textFrames["TXXX:musicbrainz_albumartistid"]) + ?.let { rawSong.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] ?: textFrames["TPE2"]) @@ -261,7 +267,9 @@ private class TagWorkerImpl( private fun populateWithVorbis(comments: Map>) { // Song - comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } + (comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let { + rawSong.musicBrainzId = it.first() + } comments["title"]?.let { rawSong.name = it.first() } comments["titlesort"]?.let { rawSong.sortName = it.first() } @@ -290,20 +298,28 @@ private class TagWorkerImpl( ?.let { rawSong.date = it } // Album - comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() } + (comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let { + rawSong.albumMusicBrainzId = it.first() + } comments["album"]?.let { rawSong.albumName = it.first() } comments["albumsort"]?.let { rawSong.albumSortName = it.first() } - comments["releasetype"]?.let { rawSong.releaseTypes = it } + (comments["releasetype"] ?: comments["musicbrainz album type"])?.let { + rawSong.releaseTypes = it + } // Artist - comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } + (comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let { + rawSong.artistMusicBrainzIds = it + } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) ?.let { rawSong.artistSortNames = it } // Album artist - comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } + (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let { + rawSong.albumArtistMusicBrainzIds = it + } (comments["albumartists"] ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]) From 296d9c3ca3e82f2ef5d0da6dc634a6f1ca9188f6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 20 Jun 2024 21:25:32 -0600 Subject: [PATCH 107/110] music: disable perceptual cover art keying Too slow, need to aggressively optimize the music loader before even THINKING about this, and if anything likely defer it. --- .../oxycblt/auxio/image/extractor/DHash.kt | 1 + .../auxio/music/cache/CacheDatabase.kt | 2 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 27 ++++++++++++++----- .../auxio/music/service/MediaItemBrowser.kt | 2 -- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt index 0b0949efd..1e7809606 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt @@ -26,6 +26,7 @@ import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import java.math.BigInteger +@Suppress("UNUSED") fun Bitmap.dHash(hashSize: Int = 16): String { // Step 1: Resize the bitmap to a fixed size val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true) diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 00f8eb43b..2a7113066 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 45, exportSchema = false) +@Database(entities = [CachedSong::class], version = 46, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index fe8ca0c46..d30e5324e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.music.metadata -import android.graphics.BitmapFactory import androidx.core.text.isDigitsOnly import androidx.media3.common.MediaItem import androidx.media3.exoplayer.MetadataRetriever @@ -26,8 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject +import kotlin.math.min import org.oxycblt.auxio.image.extractor.CoverExtractor -import org.oxycblt.auxio.image.extractor.dHash import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.info.Date @@ -106,10 +105,24 @@ private class TagWorkerImpl( populateWithId3v2(textTags.id3v2) populateWithVorbis(textTags.vorbis) - val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata) - val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } - rawSong.coverPerceptualHash = bitmap?.dHash() - bitmap?.recycle() + coverExtractor.findCoverDataInMetadata(metadata)?.use { + val available = it.available() + val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong()) + it.skip(skip) + val bytes = ByteArray(COVER_KEY_SAMPLE) + it.read(bytes) + + @OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString() + + rawSong.coverPerceptualHash = byteString + } + + // OPTIONAL: Nicer cover art keying using an actual perceptual hash + // Really bad idea if you have big cover arts. Okay idea if you have different + // formats for the same cover art. + // val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } + // rawSong.coverPerceptualHash = bitmap?.dHash() + // bitmap?.recycle() // OPUS base gain interpretation code: This is likely not needed, as the media player // should be using the base gain already. Uncomment if that's not the case. @@ -376,6 +389,8 @@ private class TagWorkerImpl( first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull() private companion object { + val COVER_KEY_SAMPLE = 32 + val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_RELEASE_TYPES = listOf("compilation") diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 0ca607167..93841a63f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -61,8 +61,6 @@ constructor( private var invalidator: Invalidator? = null interface Invalidator { - data class ParentId(val id: String, val itemCount: Int) - fun invalidate(ids: Map) fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) From 17d16d20c7e7c3a848ac426a740ec0487c650df2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 20 Jun 2024 21:55:02 -0600 Subject: [PATCH 108/110] info: update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0dafdad5..32e65b5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,16 @@ #### What's Improved - Album covers are now loaded on a per-song basis -- Correctly interpret MP4 sort tags +- MP4 sort tags are now correctly interpreted +- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly +- M3U paths are now interpreted both as relative and absolute regardless of the format +- Added support for M3U paths starting with /storage/ #### What's Fixed - Fixed repeat mode not restoring on startup - Fixed rewinding not occuring when skipping back at the beginning of the queue if rewind before skipping was turned off +- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used #### What's Changed - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings @@ -22,6 +26,11 @@ rewind before skipping was turned off #### dev -> dev1 changes - Re-added ability to open app from clicking on notification - Removed tasker plugin +- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly +- M3U paths are now interpreted both as relative and absolute regardless of the format +- Added support for M3U paths starting with /storage/ +- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used +- Made album cover keying more efficient at the cost of resillients ## 3.4.3 From e831fbc7734a9aebf9d5b479aa5401e2e3b8c8b3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 20 Jun 2024 22:13:46 -0600 Subject: [PATCH 109/110] build: bump to 3.5.0 Bump the version to 3.5.0 (46). --- CHANGELOG.md | 5 +++-- app/build.gradle | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e65b5de..acfb63579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,14 +23,15 @@ rewind before skipping was turned off - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings - Playback will close automatically after some time left idle -#### dev -> dev1 changes +#### dev -> release changes - Re-added ability to open app from clicking on notification - Removed tasker plugin - Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly - M3U paths are now interpreted both as relative and absolute regardless of the format - Added support for M3U paths starting with /storage/ - Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used -- Made album cover keying more efficient at the cost of resillients +- Made album cover keying more efficient at the cost of resillience +- Fixed android auto queue not respecting shuffle ## 3.4.3 diff --git a/app/build.gradle b/app/build.gradle index f5bf46f91..9d1778b5a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.5.0-dev" - versionCode 45 + versionName "3.5.0" + versionCode 46 minSdk 24 targetSdk 34 From cc2d740b6146260b0ac868aca4f0221115d3ada0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 20 Jun 2024 22:34:09 -0600 Subject: [PATCH 110/110] info: update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acfb63579..70f8b8257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly - M3U paths are now interpreted both as relative and absolute regardless of the format - Added support for M3U paths starting with /storage/ +- Queue no longer scrolls as quickly when dragging items #### What's Fixed - Fixed repeat mode not restoring on startup @@ -23,6 +24,9 @@ rewind before skipping was turned off - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings - Playback will close automatically after some time left idle +#### Dev/Meta +- Use WEBP instead of PNG icons + #### dev -> release changes - Re-added ability to open app from clicking on notification - Removed tasker plugin