Compare commits
730 commits
widget-fix
...
dev
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e2918d3a95 | ||
![]() |
01a159754d | ||
![]() |
6a42f7c5d2 | ||
![]() |
e9b3649156 | ||
![]() |
94795fe24c | ||
![]() |
ef7ef8da95 | ||
![]() |
102ed85c42 | ||
![]() |
273dc971ba | ||
![]() |
a3722acb5a | ||
![]() |
93953aee8b | ||
![]() |
a71ef0daf2 | ||
![]() |
44633142d9 | ||
![]() |
9e683a7856 | ||
![]() |
5825ec3ebc | ||
![]() |
132b689b0c | ||
![]() |
e7454e636b | ||
![]() |
159159b889 | ||
![]() |
b630063f8c | ||
![]() |
6b6fc4d62a | ||
![]() |
93dee00285 | ||
![]() |
e73dffcb2a | ||
![]() |
296bd9ca06 | ||
![]() |
7429dd5174 | ||
![]() |
6705e869da | ||
![]() |
77c9151006 | ||
![]() |
04e4ea82ed | ||
![]() |
a9707cbb33 | ||
![]() |
f213c21225 | ||
![]() |
e64b30f00f | ||
![]() |
3df6e2f0b1 | ||
![]() |
7523298237 | ||
![]() |
b21b2e49d3 | ||
![]() |
eaba11fa44 | ||
![]() |
1193ef0bb9 | ||
![]() |
aac6d8ef4d | ||
![]() |
343856ac69 | ||
![]() |
90282f0f74 | ||
![]() |
63227a1f1f | ||
![]() |
73b2b92180 | ||
![]() |
daf1687426 | ||
![]() |
8023d2c037 | ||
![]() |
c2dcbd61f8 | ||
![]() |
b3c66d9b55 | ||
![]() |
652f0891fc | ||
![]() |
2f5b78dd84 | ||
![]() |
b8733a180c | ||
![]() |
b573fd2260 | ||
![]() |
436ef8de91 | ||
![]() |
05e864e7b5 | ||
![]() |
f030b440f6 | ||
![]() |
513fd98047 | ||
![]() |
f125e37e95 | ||
![]() |
219d26b4dc | ||
![]() |
879caf17db | ||
![]() |
cd535eda2e | ||
![]() |
e2d28f98f4 | ||
![]() |
9a70ae1c4e | ||
![]() |
f5483b5bc5 | ||
![]() |
e3715d3b2d | ||
![]() |
0d05b94884 | ||
![]() |
afa094d753 | ||
![]() |
cfa4fc30e1 | ||
![]() |
971c0e3a25 | ||
![]() |
9937e773a3 | ||
![]() |
70b26dfb63 | ||
![]() |
ac1fec74da | ||
![]() |
d62c85f8a5 | ||
![]() |
4de42a3a55 | ||
![]() |
4821051d34 | ||
![]() |
10a520e812 | ||
![]() |
95f615e980 | ||
![]() |
e434c4cdfe | ||
![]() |
d9afc6a0eb | ||
![]() |
a2af205c71 | ||
![]() |
6b8c0faf44 | ||
![]() |
e092d81cf2 | ||
![]() |
46806ee31f | ||
![]() |
84a7393221 | ||
![]() |
518cd28c03 | ||
![]() |
fe770337e6 | ||
![]() |
1fc9ca5147 | ||
![]() |
387a36a3f8 | ||
![]() |
20a06ba2fb | ||
![]() |
e046aeb671 | ||
![]() |
22249cc95b | ||
![]() |
6feee93438 | ||
![]() |
0d0a20d760 | ||
![]() |
f0ea0a3e2e | ||
![]() |
859e31d825 | ||
![]() |
b48bf3729e | ||
![]() |
4fbbbfdc76 | ||
![]() |
a7000bc9e5 | ||
![]() |
8104985a4e | ||
![]() |
fce77ec8a0 | ||
![]() |
a78b213537 | ||
![]() |
ce5f0fa2c9 | ||
![]() |
2e4a147b55 | ||
![]() |
216d9802ef | ||
![]() |
7906867a96 | ||
![]() |
25901a0f76 | ||
![]() |
403f93b6df | ||
![]() |
0bbba2efaf | ||
![]() |
3741f1ff07 | ||
![]() |
b388474655 | ||
![]() |
584af83a07 | ||
![]() |
0387400a4a | ||
![]() |
94f8457d69 | ||
![]() |
876554e6c7 | ||
![]() |
22b231843f | ||
![]() |
be270a422b | ||
![]() |
10eb0be7d0 | ||
![]() |
e2b0601d4c | ||
![]() |
ddeba2c496 | ||
![]() |
59c33b9be2 | ||
![]() |
cacf0142c5 | ||
![]() |
fbcd676149 | ||
![]() |
6cc1e8a543 | ||
![]() |
8a8fd0f3c9 | ||
![]() |
98299722bc | ||
![]() |
91b8b38732 | ||
![]() |
d6c2514473 | ||
![]() |
50e2dde6e2 | ||
![]() |
582b0c6eef | ||
![]() |
3834e92192 | ||
![]() |
117678a066 | ||
![]() |
b306456d46 | ||
![]() |
1d44ce5d71 | ||
![]() |
bfcaba4acd | ||
![]() |
442abb7040 | ||
![]() |
251197b47b | ||
![]() |
52e359d431 | ||
![]() |
f21ef6cf85 | ||
![]() |
c609e1d63a | ||
![]() |
b6af921238 | ||
![]() |
528389546c | ||
![]() |
2ff08ac813 | ||
![]() |
db4e927780 | ||
![]() |
c3aba06e2f | ||
![]() |
3d374504e2 | ||
![]() |
a6a98f9bf7 | ||
![]() |
a7969f99c3 | ||
![]() |
dddab1eda7 | ||
![]() |
9ae3587a7e | ||
![]() |
6589cd44eb | ||
![]() |
bfdccd3ba5 | ||
![]() |
357c7cc329 | ||
![]() |
e442fcf253 | ||
![]() |
a1d62c2a08 | ||
![]() |
3d154ea66c | ||
![]() |
3efd4ea59f | ||
![]() |
9632e06ca6 | ||
![]() |
210285b39a | ||
![]() |
15d2faf354 | ||
![]() |
1459498ff3 | ||
![]() |
ef732219d7 | ||
![]() |
431f541ec8 | ||
![]() |
fb2f228a97 | ||
![]() |
72ffac4209 | ||
![]() |
ee7e63d1dc | ||
![]() |
f9109b8a9c | ||
![]() |
2e4b6681d1 | ||
![]() |
a0c82ac812 | ||
![]() |
c881a1c5b4 | ||
![]() |
e78fde44e0 | ||
![]() |
7880c777ba | ||
![]() |
7c8863bd3a | ||
![]() |
97bd259728 | ||
![]() |
e3e19fb0ac | ||
![]() |
9685f3cf51 | ||
![]() |
9d22cc37b8 | ||
![]() |
d49286981c | ||
![]() |
0785711cd6 | ||
![]() |
a0e10ef8dd | ||
![]() |
1bf44eba91 | ||
![]() |
3aae8ea534 | ||
![]() |
b81ecf44c0 | ||
![]() |
020c6900a5 | ||
![]() |
4d704e86a6 | ||
![]() |
ad2ec5a655 | ||
![]() |
b0b55b5069 | ||
![]() |
c9d4b01f9f | ||
![]() |
b6d80189ca | ||
![]() |
71aa887438 | ||
![]() |
b108970fe5 | ||
![]() |
f28f2dd9f7 | ||
![]() |
847d5aa1fc | ||
![]() |
e1f07def10 | ||
![]() |
f134d3e11b | ||
![]() |
10aaf0afd2 | ||
![]() |
a1289ffaca | ||
![]() |
ad4b9a3859 | ||
![]() |
08e09af5b3 | ||
![]() |
cc6c5084ff | ||
![]() |
2f43113ce2 | ||
![]() |
04e871f421 | ||
![]() |
698f0bc13c | ||
![]() |
85a2952ae1 | ||
![]() |
c35902a6aa | ||
![]() |
1132e486ca | ||
![]() |
e6b326a571 | ||
![]() |
ae6a0438be | ||
![]() |
c359048721 | ||
![]() |
29320f426e | ||
![]() |
8bd89c5967 | ||
![]() |
9b82b5aee0 | ||
![]() |
c5241dec60 | ||
![]() |
998375f28a | ||
![]() |
e0059e9dc0 | ||
![]() |
3d690eb637 | ||
![]() |
0e34a28dfb | ||
![]() |
8c3750778f | ||
![]() |
802e215482 | ||
![]() |
6ee43b106f | ||
![]() |
f8ec77e137 | ||
![]() |
4a08809e50 | ||
![]() |
8c4b8dfb56 | ||
![]() |
ff074d0e3a | ||
![]() |
3bd4027802 | ||
![]() |
6f2b7abbef | ||
![]() |
58e0956cad | ||
![]() |
e94b74edd4 | ||
![]() |
b3f4fdfb4a | ||
![]() |
e519e8f8be | ||
![]() |
ed3e0845d6 | ||
![]() |
5375c862b3 | ||
![]() |
4318e70052 | ||
![]() |
7b9c14a118 | ||
![]() |
0ead77d6e6 | ||
![]() |
6a6d15f3e8 | ||
![]() |
605800e9a5 | ||
![]() |
447f2da294 | ||
![]() |
3b97c61b7d | ||
![]() |
2b46774215 | ||
![]() |
1d84ba23b4 | ||
![]() |
fdf71cedd2 | ||
![]() |
5e168860e7 | ||
![]() |
6587d2259b | ||
![]() |
b328a6ea03 | ||
![]() |
298a30da6d | ||
![]() |
bbc4db156e | ||
![]() |
1fb6097b9d | ||
![]() |
9952579cc4 | ||
![]() |
6d09e06424 | ||
![]() |
3e54c032fe | ||
![]() |
6be97943bc | ||
![]() |
4679785b78 | ||
![]() |
9fe508a906 | ||
![]() |
156b2fe1f0 | ||
![]() |
4809bf50cc | ||
![]() |
d486dc39cc | ||
![]() |
710e279d8f | ||
![]() |
9166580703 | ||
![]() |
32b152e155 | ||
![]() |
a4d7b54db7 | ||
![]() |
fddd527975 | ||
![]() |
3431e13cde | ||
![]() |
2d5ca0b351 | ||
![]() |
07a0d01a06 | ||
![]() |
b4a9f9af96 | ||
![]() |
b0faad6380 | ||
![]() |
20be8c17fe | ||
![]() |
3007ad3ced | ||
![]() |
92a07e346b | ||
![]() |
7e6865c6b3 | ||
![]() |
533702ca1e | ||
![]() |
171c0c795e | ||
![]() |
4c58590cb0 | ||
![]() |
0c7adc9d17 | ||
![]() |
88d5d398c5 | ||
![]() |
d5b2397511 | ||
![]() |
1594340046 | ||
![]() |
ab81995d1c | ||
![]() |
bf9b842407 | ||
![]() |
f5ac87a36b | ||
![]() |
ecc8d8750a | ||
![]() |
be666069fc | ||
![]() |
b65481dd9c | ||
![]() |
56ff872f04 | ||
![]() |
c3ccb8519e | ||
![]() |
9161b8f777 | ||
![]() |
0f4a550775 | ||
![]() |
028fff4c42 | ||
![]() |
d61c2852e6 | ||
![]() |
bb8dfdb28a | ||
![]() |
a2e6bcbb7f | ||
![]() |
194e6b1574 | ||
![]() |
62e214039f | ||
![]() |
75455b1b90 | ||
![]() |
2401f9031f | ||
![]() |
04e81916f7 | ||
![]() |
68098b97ed | ||
![]() |
ef751f1a11 | ||
![]() |
7497ff2514 | ||
![]() |
c6dc51659b | ||
![]() |
9ccc4cf2ae | ||
![]() |
64ce312976 | ||
![]() |
25ca3e3046 | ||
![]() |
e78e71e3a7 | ||
![]() |
a1cd4f7b26 | ||
![]() |
ff6d2fe228 | ||
![]() |
c6e83d1e18 | ||
![]() |
d3f4ed5dd4 | ||
![]() |
d964df4616 | ||
![]() |
b05d668b5e | ||
![]() |
292ea9d8a1 | ||
![]() |
ebcedb49eb | ||
![]() |
8b3d7cae9c | ||
![]() |
32156f23b2 | ||
![]() |
8b58f357cb | ||
![]() |
7b35ba840b | ||
![]() |
0dc72b67af | ||
![]() |
80c97cbea1 | ||
![]() |
b8178056f5 | ||
![]() |
dc8cbc74e8 | ||
![]() |
5e7d575efd | ||
![]() |
8d49893309 | ||
![]() |
75612dd1eb | ||
![]() |
61fd11fe04 | ||
![]() |
3f364dc5c6 | ||
![]() |
4f920e922d | ||
![]() |
da76a03298 | ||
![]() |
ca6388b28d | ||
![]() |
c42ac644eb | ||
![]() |
7768d98632 | ||
![]() |
a24d102a00 | ||
![]() |
0cfd6ddb67 | ||
![]() |
8409a93c4e | ||
![]() |
9a7b970346 | ||
![]() |
258418578a | ||
![]() |
bdce83f047 | ||
![]() |
75ca315b9b | ||
![]() |
518b80bdf2 | ||
![]() |
c379174ffe | ||
![]() |
b6bc065a4a | ||
![]() |
6652e351cf | ||
![]() |
6ccae5f0d2 | ||
![]() |
e56e290451 | ||
![]() |
77f97ef656 | ||
![]() |
07118a5ff1 | ||
![]() |
44696424a9 | ||
![]() |
a888d09a2c | ||
![]() |
787a78f845 | ||
![]() |
046a02de00 | ||
![]() |
b6cbf97df9 | ||
![]() |
6dd70af10c | ||
![]() |
6fd0bd411b | ||
![]() |
6f8a960ee1 | ||
![]() |
001db620e3 | ||
![]() |
9a38877c2e | ||
![]() |
503a4854c3 | ||
![]() |
a4cca0ca79 | ||
![]() |
ef502b6f4a | ||
![]() |
2ec3bbbe8c | ||
![]() |
b9c8933021 | ||
![]() |
45c3d3f4bc | ||
![]() |
c4a4b69cd1 | ||
![]() |
2842bd57b1 | ||
![]() |
0f0b7a4a7d | ||
![]() |
5ff949c49c | ||
![]() |
6bad9e719d | ||
![]() |
9f68f59504 | ||
![]() |
a598f39dea | ||
![]() |
1843986f75 | ||
![]() |
8b69042288 | ||
![]() |
8cc939b58d | ||
![]() |
249d2fad67 | ||
![]() |
a77dd3ff7a | ||
![]() |
7e8764d6d4 | ||
![]() |
c431e90af8 | ||
![]() |
03ee8d299d | ||
![]() |
7b1ccfc3fb | ||
![]() |
acd4dab74c | ||
![]() |
a1188b8d4b | ||
![]() |
7df5c5973e | ||
![]() |
3fbb33e3e4 | ||
![]() |
993060212b | ||
![]() |
973c940042 | ||
![]() |
7bd7b01a0b | ||
![]() |
93da4a69a9 | ||
![]() |
7e45812411 | ||
![]() |
3ad2fd2fc0 | ||
![]() |
b3a598c558 | ||
![]() |
744097694f | ||
![]() |
f4822a4e40 | ||
![]() |
9f657adf94 | ||
![]() |
bdfd9d6e23 | ||
![]() |
f3913b148a | ||
![]() |
8bbb7497a6 | ||
![]() |
6850a3443f | ||
![]() |
50b7c24c03 | ||
![]() |
880967f8be | ||
![]() |
7fab7f7eeb | ||
![]() |
3d94ab67cf | ||
![]() |
a50b55cf70 | ||
![]() |
11a4d6a720 | ||
![]() |
ac1c31cacb | ||
![]() |
ee0c643115 | ||
![]() |
ad183bdbfd | ||
![]() |
d0845ef325 | ||
![]() |
b6f6213ac4 | ||
![]() |
6e3b03d4c6 | ||
![]() |
50bfe9926b | ||
![]() |
4421f4f56d | ||
![]() |
9d1978850b | ||
![]() |
00520f7fda | ||
![]() |
5a65a6aa25 | ||
![]() |
47d5184e8d | ||
![]() |
0d5abb6407 | ||
![]() |
14355a1005 | ||
![]() |
4d0465e012 | ||
![]() |
ed102d3414 | ||
![]() |
18c5b3618c | ||
![]() |
d4d00249df | ||
![]() |
71667f378d | ||
![]() |
ae44abc35a | ||
![]() |
e908d0e102 | ||
![]() |
f33377cf26 | ||
![]() |
479dca4452 | ||
![]() |
31e092a649 | ||
![]() |
b5657f0202 | ||
![]() |
e9c15bfbef | ||
![]() |
cb84b2db17 | ||
![]() |
e3146647d3 | ||
![]() |
c5cd404393 | ||
![]() |
de1c091517 | ||
![]() |
3da9e6c5b3 | ||
![]() |
c70c27a7b4 | ||
![]() |
9ab4dc5595 | ||
![]() |
e16b23f34e | ||
![]() |
a2498db6e5 | ||
![]() |
65151e006f | ||
![]() |
2f98d67855 | ||
![]() |
93a602b592 | ||
![]() |
993dbbf8c1 | ||
![]() |
a593f2874d | ||
![]() |
76eb98c3af | ||
![]() |
5fae4601de | ||
![]() |
59df1c3d57 | ||
![]() |
34217696c2 | ||
![]() |
a60239c6f7 | ||
![]() |
29f82c0963 | ||
![]() |
44de732247 | ||
![]() |
34be5fb2a5 | ||
![]() |
5042d3f5f2 | ||
![]() |
be54ee9c18 | ||
![]() |
55e77707ea | ||
![]() |
7640292d7a | ||
![]() |
8c865fb581 | ||
![]() |
1289922cd9 | ||
![]() |
c7dfae5262 | ||
![]() |
a5d7d47aba | ||
![]() |
abb547aba3 | ||
![]() |
a85acceed6 | ||
![]() |
1c85dc96e0 | ||
![]() |
d3f75439fc | ||
![]() |
63193809b0 | ||
![]() |
88f43a7906 | ||
![]() |
6d85f43304 | ||
![]() |
0ce3a11f82 | ||
![]() |
cf69b27134 | ||
![]() |
8b4672ea50 | ||
![]() |
f13c1e364b | ||
![]() |
42390f4b3f | ||
![]() |
b53b7a0c6a | ||
![]() |
530d8cc2b5 | ||
![]() |
45ead8253a | ||
![]() |
8adda19d1a | ||
![]() |
df1faa11e4 | ||
![]() |
2592aca4bf | ||
![]() |
3528392f95 | ||
![]() |
0f8294bf43 | ||
![]() |
501c79d23c | ||
![]() |
1d0ad641d5 | ||
![]() |
efceefc221 | ||
![]() |
ced2adb2c6 | ||
![]() |
c270759dec | ||
![]() |
2a38d1ae8d | ||
![]() |
3eaa96ffda | ||
![]() |
abeabcb8df | ||
![]() |
75c2d7cd16 | ||
![]() |
970fdb2a8d | ||
![]() |
7f7ee94f45 | ||
![]() |
7582c8c9cf | ||
![]() |
59652b2f9b | ||
![]() |
49aa3c2891 | ||
![]() |
43c05e7096 | ||
![]() |
dfff01bd28 | ||
![]() |
523d3cdd30 | ||
![]() |
86a77bc19b | ||
![]() |
e647c31c56 | ||
![]() |
a3da28fb84 | ||
![]() |
a22e972bd3 | ||
![]() |
6b8b147721 | ||
![]() |
e061f7cb26 | ||
![]() |
c74c62d9b3 | ||
![]() |
3dbe06c0bc | ||
![]() |
f57ee549f1 | ||
![]() |
ab442f99c1 | ||
![]() |
1a3fe7c075 | ||
![]() |
94a580aaed | ||
![]() |
b832ac8639 | ||
![]() |
c3f9f0d80e | ||
![]() |
ddfe10b869 | ||
![]() |
7a7774a4db | ||
![]() |
37697abfce | ||
![]() |
b30aba4bdf | ||
![]() |
a30e6db71d | ||
![]() |
1b295934e0 | ||
![]() |
d52e301751 | ||
![]() |
ffaff6f08e | ||
![]() |
c9d370048f | ||
![]() |
b31562e250 | ||
![]() |
e0bbb88e92 | ||
![]() |
dd3b411beb | ||
![]() |
ae449ded45 | ||
![]() |
c74b744aec | ||
![]() |
c87ff7bb92 | ||
![]() |
dba11a61b4 | ||
![]() |
1962fbe70a | ||
![]() |
cc9bb167c4 | ||
![]() |
ec19808cf1 | ||
![]() |
144da8a3b5 | ||
![]() |
ba5f51dfe6 | ||
![]() |
6e4e818fd4 | ||
![]() |
38ed432555 | ||
![]() |
4618996fc5 | ||
![]() |
b0c6dd2b74 | ||
![]() |
0ba5ddce51 | ||
![]() |
9d9f810356 | ||
![]() |
3bf80073f4 | ||
![]() |
2f9ced2ac3 | ||
![]() |
ba29905aa6 | ||
![]() |
e3d6644634 | ||
![]() |
608e249a87 | ||
![]() |
9a990096da | ||
![]() |
c7f4f842f3 | ||
![]() |
db391da4b8 | ||
![]() |
d633a6b9f1 | ||
![]() |
73ff7e2c7f | ||
![]() |
c4f4797028 | ||
![]() |
ba9ab5a445 | ||
![]() |
517da485e1 | ||
![]() |
c022be6e4d | ||
![]() |
806fabc89a | ||
![]() |
556c5d5e0a | ||
![]() |
f76eafc9d4 | ||
![]() |
e51b2817e9 | ||
![]() |
cdc5a37bfa | ||
![]() |
b651a3be03 | ||
![]() |
01a5e87a77 | ||
![]() |
53d0dbd0cb | ||
![]() |
cadd2d1231 | ||
![]() |
5b447f7efb | ||
![]() |
300f26739d | ||
![]() |
4d27c444de | ||
![]() |
f783a9c32f | ||
![]() |
85bd1f0062 | ||
![]() |
d6e09dcf2a | ||
![]() |
c2d18b77f6 | ||
![]() |
fe6c07a342 | ||
![]() |
8ec61c9388 | ||
![]() |
1d19d00798 | ||
![]() |
211b815a20 | ||
![]() |
f25c98aa7e | ||
![]() |
2db23369e3 | ||
![]() |
ad760d4da1 | ||
![]() |
075f6c3da3 | ||
![]() |
d06dd59386 | ||
![]() |
022fe9ae1b | ||
![]() |
6406b49501 | ||
![]() |
d7f3c58fd9 | ||
![]() |
82ddd3a24e | ||
![]() |
3753b5f0cc | ||
![]() |
0ed7938be9 | ||
![]() |
018e142ee9 | ||
![]() |
97b0a8aa68 | ||
![]() |
bd685f1f9c | ||
![]() |
bfeae6a5a9 | ||
![]() |
5751725e8e | ||
![]() |
b0af681390 | ||
![]() |
47fa41715d | ||
![]() |
147f7f426c | ||
![]() |
64fbd0acbf | ||
![]() |
89110c2798 | ||
![]() |
59fd4b5e18 | ||
![]() |
22ce9988c8 | ||
![]() |
50829a54d3 | ||
![]() |
de36f26394 | ||
![]() |
bba4ae81e7 | ||
![]() |
4befe1910f | ||
![]() |
64354f7f6e | ||
![]() |
15121d28f6 | ||
![]() |
9a01fe471e | ||
![]() |
0f4702c4dd | ||
![]() |
7dfaea3a4b | ||
![]() |
22ddda4e60 | ||
![]() |
c1514d6029 | ||
![]() |
f7488f7b0d | ||
![]() |
a46fa85d67 | ||
![]() |
fbd94f1a21 | ||
![]() |
745bff268f | ||
![]() |
6d72240336 | ||
![]() |
a9a35c8055 | ||
![]() |
9883cf1c91 | ||
![]() |
1ee5645780 | ||
![]() |
6c9f170afc | ||
![]() |
03be2ef028 | ||
![]() |
ea405b1cb8 | ||
![]() |
4ef021f664 | ||
![]() |
b4d6c0a611 | ||
![]() |
d1e8cc3320 | ||
![]() |
3ff681b870 | ||
![]() |
b2f10ea5ef | ||
![]() |
4a6273e2da | ||
![]() |
9b4e9b30b2 | ||
![]() |
caa2e02aff | ||
![]() |
3898646691 | ||
![]() |
226f078aa4 | ||
![]() |
97faa3f20e | ||
![]() |
190abd5588 | ||
![]() |
d2524a0b3a | ||
![]() |
d540d6f14c | ||
![]() |
8d767a0aac | ||
![]() |
18f96ed3ec | ||
![]() |
e23643f3ab | ||
![]() |
344a49532b | ||
![]() |
29e29d3cab | ||
![]() |
10d7f5d197 | ||
![]() |
bed1dc43cd | ||
![]() |
19f3e07c8e | ||
![]() |
0b3a136320 | ||
![]() |
adfed98b71 | ||
![]() |
1a78e973d7 | ||
![]() |
437d3391e7 | ||
![]() |
c236a449c8 | ||
![]() |
f0bda0c99f | ||
![]() |
cb43b0f074 | ||
![]() |
f4589616be | ||
![]() |
c9664d75c0 | ||
![]() |
f84e3428f0 | ||
![]() |
d6a0b75618 | ||
![]() |
6f3fc5904a | ||
![]() |
3afbedb6bd | ||
![]() |
2bd468bce3 | ||
![]() |
751cd94736 | ||
![]() |
e12ce82615 | ||
![]() |
14035956e6 | ||
![]() |
cbdad3fe39 | ||
![]() |
26f27d0edd | ||
![]() |
94c4840672 | ||
![]() |
f4e1681044 | ||
![]() |
a3af24688a | ||
![]() |
8418dccdc6 | ||
![]() |
d2aed8ee23 | ||
![]() |
fcd4ef3dc8 | ||
![]() |
3832c4e525 | ||
![]() |
29d663f500 | ||
![]() |
e4310cfe17 | ||
![]() |
fd597ea16a | ||
![]() |
2857f7d92c | ||
![]() |
48568d2a1d | ||
![]() |
3af81404ac | ||
![]() |
4e4a99bbf3 | ||
![]() |
2bc4ed020b | ||
![]() |
6ff2d55a68 | ||
![]() |
463b02f871 | ||
![]() |
a29f747341 | ||
![]() |
b43dbb3e89 | ||
![]() |
bf50867b37 | ||
![]() |
889713d5e0 | ||
![]() |
130d30c70d | ||
![]() |
a712a773b0 | ||
![]() |
b2e7c1eb50 | ||
![]() |
fda4548515 | ||
![]() |
cce33e1414 | ||
![]() |
ba5bccaa37 | ||
![]() |
3dea060a28 | ||
![]() |
44f9617307 | ||
![]() |
916c3c46df | ||
![]() |
f1e1152e21 | ||
![]() |
e23ac33b85 | ||
![]() |
66c31f4318 | ||
![]() |
30b3603cf1 | ||
![]() |
f30c426c77 | ||
![]() |
35646d6a2d | ||
![]() |
f0dda6c43e | ||
![]() |
924e3d1801 | ||
![]() |
b1e871c6e1 | ||
![]() |
69070e7b13 | ||
![]() |
e43f55bc78 | ||
![]() |
b8e54b3707 | ||
![]() |
99c11bd27f | ||
![]() |
c1e5adbc44 | ||
![]() |
f251813200 | ||
![]() |
d91343070a | ||
![]() |
a3012abe23 | ||
![]() |
3cd09c3cec | ||
![]() |
1a490eb7b4 | ||
![]() |
258dd9205c | ||
![]() |
e1f75bb337 | ||
![]() |
2c976374f3 | ||
![]() |
cc7f9ba539 | ||
![]() |
27e378ae2a | ||
![]() |
b8a652d6f2 | ||
![]() |
2e9647d1dc | ||
![]() |
ea9c5d3c88 | ||
![]() |
3fa5628a1e | ||
![]() |
aa140bebaa | ||
![]() |
5c779f6d89 | ||
![]() |
dad0d75d97 | ||
![]() |
67e51ab54c | ||
![]() |
ba46895ad1 | ||
![]() |
d10f84efa8 | ||
![]() |
7a00c3c6aa | ||
![]() |
cf28adc5aa | ||
![]() |
ba56fe1a4a | ||
![]() |
d55db7bde4 | ||
![]() |
4c20ca2a5c | ||
![]() |
f0bf7af7b4 | ||
![]() |
86d9d957a2 | ||
![]() |
9299e03e95 | ||
![]() |
e351a91a9c | ||
![]() |
9bc27a49eb | ||
![]() |
9b272bbdfe | ||
![]() |
b020285e9f | ||
![]() |
7d8efce28b | ||
![]() |
a2d4b6e50b | ||
![]() |
4a57d85037 | ||
![]() |
cf887cacb7 | ||
![]() |
24dbd04ca6 |
519 changed files with 22237 additions and 14621 deletions
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,6 +34,7 @@ body:
|
|||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 15
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12L
|
||||
|
|
14
.github/workflows/android.yml
vendored
14
.github/workflows/android.yml
vendored
|
@ -11,24 +11,28 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install ninja-build
|
||||
run: sudo apt-get install -y ninja-build
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive --remote
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Test app with Gradle
|
||||
run: ./gradlew app:testDebug
|
||||
- name: Check formatting with spotless
|
||||
run: ./gradlew spotlessCheck
|
||||
- name: Test musikr with Gradle
|
||||
run: ./gradlew musikr:testDebug
|
||||
- name: Build debug APK with Gradle
|
||||
run: ./gradlew app:packageDebug
|
||||
- name: Upload debug APK artifact
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Auxio_Canary
|
||||
path: ./app/build/outputs/apk/debug/app-debug.apk
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -13,3 +13,6 @@ captures/
|
|||
.externalNativeBuild
|
||||
*.iml
|
||||
.cxx
|
||||
.kotlin
|
||||
.aider*
|
||||
.env
|
||||
|
|
5
.gitmodules
vendored
5
.gitmodules
vendored
|
@ -1,3 +1,8 @@
|
|||
[submodule "media"]
|
||||
path = media
|
||||
url = https://github.com/OxygenCobalt/media.git
|
||||
|
||||
[submodule "musikr/src/main/cpp/taglib"]
|
||||
path = musikr/src/main/cpp/taglib
|
||||
url = https://github.com/taglib/taglib.git
|
||||
tag = ee1931b
|
||||
|
|
153
CHANGELOG.md
153
CHANGELOG.md
|
@ -1,19 +1,162 @@
|
|||
# Changelog
|
||||
|
||||
## 4.0.3
|
||||
|
||||
#### What's Improved
|
||||
- Improved music loader pipeline efficiency
|
||||
- Made cover.png support more flexible
|
||||
- Albums with the same name but different album artists are now split
|
||||
if fully tagged with album artists
|
||||
|
||||
#### What's Fixed
|
||||
- Possibly fixed cache failures on large libraries
|
||||
- Possibly fixed playback state saving failing on some devices
|
||||
- Fixed issue where artists w/o songs would not have a cover
|
||||
- Fixed music not being reloaded when music locations changed
|
||||
- Fixed tasker media control not working
|
||||
- Fixed tasker playback start command never finishing
|
||||
|
||||
#### Dev/Meta
|
||||
- Removed useless storage permissions
|
||||
- Internal cleanup/simplification of musikr API
|
||||
- Removed unused resources
|
||||
|
||||
#### What's Fixed
|
||||
|
||||
## 4.0.2
|
||||
|
||||
#### What's New
|
||||
- Added back in support for cover art from cover.png/cover.jpg
|
||||
- Added "As is" cover art setting
|
||||
- Option to include hidden files or not (off by default)
|
||||
|
||||
#### What's Improved
|
||||
- Reduced elevation contrast in black theme
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed incorrect extension stripping on some files
|
||||
- Fixed various errors in new branding
|
||||
- Fixed MTE segfault from improper string handling
|
||||
|
||||
#### What's Changed
|
||||
- Hidden files no longer loaded by default
|
||||
|
||||
## 4.0.1
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading hanging on files without tags
|
||||
- Fixed playlists being destroyed in poorly tagged libraries
|
||||
|
||||
## 4.0.0
|
||||
|
||||
#### What's New
|
||||
- New app branding and icon
|
||||
- Refreshed playback design
|
||||
- A total user interface refresh based on the latest Material Design specs
|
||||
- New theme palettes
|
||||
- Improved designs for playback and detail views
|
||||
- New app branding and icon
|
||||
- Refreshed round mode
|
||||
- Less intrusive music loading indicators
|
||||
- **Musikr**, a brand new music loading system
|
||||
- Directly accesses user files rather than unreliable media database
|
||||
- Uses faster and more capable native tag parsing
|
||||
- Stores cover data on-device for fast and high-quality access
|
||||
- New interpretation system with many quality-of-life improvements
|
||||
- Android 15 support
|
||||
|
||||
#### What's Improved
|
||||
- Initial music loading is signifigantly faster and less resource intensive
|
||||
- Album grouping no longer done with artist
|
||||
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
|
||||
- M3U playlist file name is now proposed if one cannot be found within the file
|
||||
- Duration is now parsed from certain files that previously could not be parsed
|
||||
- ID3v2 tags are now parsed from WAV files
|
||||
- NN/TT tracks/discs are now handled in Vorbis
|
||||
- Music library will is less likely to fail to respond to updates
|
||||
- Hidden audio files can now be loaded
|
||||
- Sorting songs by date now uses songs date first, before the earliest album date
|
||||
- Added working layouts for small split-screen form factors
|
||||
- Added fast scrolling in detail views
|
||||
- Added ability to make issues and make feedback e-mails in-app
|
||||
|
||||
#### What's Fixed
|
||||
- Playback no longer briefly pauses when adding songs to playlists
|
||||
- Music loader no longer spawns thousands of threads when scanning
|
||||
- Excessive CPU no longer spent showing music loading process
|
||||
- Fixed playback sheet flickering on warm start
|
||||
- No longer possible to save a sort with no direction specified
|
||||
- Fixed inconsistent corner radii in widget
|
||||
- Possibly fixed foreground start music loading failures
|
||||
- Fixed playlist view not exiting on deletion
|
||||
|
||||
#### What's Changed
|
||||
- Date added is now local to when the app discovers the file and will not
|
||||
persist long-term
|
||||
- Songs with no album are now "Unknown album" rather than folder name
|
||||
- Tab layout no longer changes depending on device configuration
|
||||
- Round mode is now on by default
|
||||
|
||||
#### Dev/Meta
|
||||
- No longer using custom logging setup
|
||||
- Music loading split off into separate musikr module
|
||||
|
||||
## 3.6.3
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed broken replaygain
|
||||
- Fixed hide collaborators being broken
|
||||
- Fixed crash when navigating to artists w/appearances
|
||||
- Fixed headers appearing on empty detail sections
|
||||
|
||||
## 3.6.2
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed broken notification close action
|
||||
|
||||
#### Dev/Meta
|
||||
- Fixed mismatched NDK versions
|
||||
|
||||
## 3.6.1
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed possible crash from poor service initalization
|
||||
- Fixed issue where it was impossible to edit playlists
|
||||
- Fixed issue where playlist would revert to older version when re-edited
|
||||
|
||||
#### Dev/Meta
|
||||
- Fixed service memory leaks
|
||||
|
||||
## 3.6.0
|
||||
|
||||
#### What's New
|
||||
- Added support for playback from google assistant
|
||||
|
||||
#### What's Improved
|
||||
- Home and detail UIs in Android Auto now reflect app sort settings
|
||||
- Album view now shows discs in android auto
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed playback briefly pausing when adding songs to playlist
|
||||
- Fixed media lists in Android Auto being truncated in some cases
|
||||
- Possibly fixed duplicated song items depending on album/all children
|
||||
- Possibly fixed truncated tab lists in android auto
|
||||
|
||||
#### Dev/Meta
|
||||
- Moved to raw media session apis rather than media3 session
|
||||
|
||||
## 3.5.3
|
||||
|
||||
#### What's New
|
||||
- Basic Tasker integration for safely starting Auxio's service
|
||||
|
||||
#### What's Improved
|
||||
- Added support for informal singular-spaced tags like `album artist` in
|
||||
file metadata
|
||||
|
||||
#### What's Fixed
|
||||
- Fix "Foreground not allowed" music loading crash from starting too early
|
||||
- Fixed widget not loading on some devices due to the cover being too large
|
||||
|
||||
## 3.5.2
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading failure from improper sort systems (For real this time)
|
||||
|
||||
## 3.5.1
|
||||
|
||||
|
|
44
README.md
44
README.md
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.5.1">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.5.1&color=64B5F6&style=flat">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||
|
@ -15,7 +15,12 @@
|
|||
</p>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
|
||||
<p align="center">
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
|
||||
<a href="https://accrescent.app/app/org.oxycblt.auxio">
|
||||
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
</p>
|
||||
|
||||
|
@ -28,14 +33,12 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
|
|||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
|
||||
</p>
|
||||
|
||||
|
||||
|
@ -61,30 +64,39 @@ precise/original dates, sort tags, and more
|
|||
- Headset autoplay
|
||||
- Stylish widgets that automatically adapt to their size
|
||||
- Completely private and offline
|
||||
- No rounded album covers (by default)
|
||||
- No rounded album covers (if you want them)
|
||||
|
||||
## Permissions
|
||||
|
||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
|
||||
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
|
||||
## Donate
|
||||
|
||||
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
|
||||
|
||||
<p align="center"><b>$16/month supporters:</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
|
||||
<br/>
|
||||
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>$8/month supporters:</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
|
||||
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
|
||||
<a href="https://github.com/gtsiam"><img src="https://avatars.githubusercontent.com/u/7459196?v=4" width=50 /></a>
|
||||
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=50 /></a>
|
||||
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
|
||||
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
|
||||
</p>
|
||||
|
||||
## Building
|
||||
|
||||
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
|
||||
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
|
||||
parsing. This adds some caveats to the build process:
|
||||
1. `cmake` and `ninja-build` must be installed before building the project.
|
||||
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
|
||||
download the external code.
|
||||
|
|
|
@ -2,7 +2,6 @@ plugins {
|
|||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "androidx.navigation.safeargs.kotlin"
|
||||
id "com.diffplug.spotless"
|
||||
id "kotlin-parcelize"
|
||||
id "dagger.hilt.android.plugin"
|
||||
id "kotlin-kapt"
|
||||
|
@ -11,21 +10,19 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
|
||||
// it here so that binary stripping will work.
|
||||
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
||||
// NDK use is unified
|
||||
ndkVersion = "25.2.9519653"
|
||||
compileSdk 35
|
||||
// Auxio implicitly depends on the native modules, explicitly specify it
|
||||
// here so the libraries are still stripped.
|
||||
ndkVersion ndk_version
|
||||
namespace "org.oxycblt.auxio"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.5.1"
|
||||
versionCode 47
|
||||
versionName "4.0.4"
|
||||
versionCode 63
|
||||
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
minSdk min_sdk
|
||||
targetSdk target_sdk
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
@ -70,6 +67,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,17 +77,15 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
def coroutines_version = '1.7.2'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
|
||||
|
||||
// --- SUPPORT ---
|
||||
|
||||
// General
|
||||
implementation "androidx.core:core-ktx:1.13.1"
|
||||
implementation "androidx.core:core-ktx:$core_version"
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "androidx.activity:activity-ktx:1.9.0"
|
||||
// Disabled since 1.7+ has completely broken progressive back gestures.
|
||||
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||
// noinspection GradleDependency
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.2"
|
||||
|
||||
|
@ -99,13 +95,13 @@ dependencies {
|
|||
// TODO: Report this issue and hope for a timely fix
|
||||
// noinspection GradleDependency
|
||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.2.0"
|
||||
// 1.1.0 upgrades recyclerview to 1.3.0, keep it on 1.0.0
|
||||
//noinspection GradleDependency
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
|
||||
// Lifecycle
|
||||
def lifecycle_version = "2.8.3"
|
||||
def lifecycle_version = "2.8.7"
|
||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
|
@ -118,30 +114,38 @@ dependencies {
|
|||
// Media
|
||||
implementation "androidx.media:media:1.7.0"
|
||||
|
||||
// Android Auto
|
||||
implementation "androidx.car.app:app:1.4.0"
|
||||
|
||||
// Preferences
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
// Database
|
||||
def room_version = '2.6.1'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
|
||||
// Build
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
|
||||
|
||||
// --- SECOND PARTY ---
|
||||
|
||||
// Musikr
|
||||
implementation project(":musikr")
|
||||
|
||||
// --- 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"
|
||||
|
||||
// Image loading
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
|
||||
|
||||
// Material
|
||||
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
|
||||
// PR a fix.
|
||||
implementation "com.google.android.material:material:1.13.0-alpha04"
|
||||
implementation "com.google.android.material:material:1.13.0-alpha07"
|
||||
|
||||
// Dependency Injection
|
||||
implementation "com.google.dagger:dagger:$hilt_version"
|
||||
|
@ -155,24 +159,9 @@ dependencies {
|
|||
// Speed dial
|
||||
implementation "com.leinardi.android:speed-dial:3.3.0"
|
||||
|
||||
// Testing
|
||||
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.11"
|
||||
testImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
}
|
||||
// Tasker integration
|
||||
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
target "src/**/*.kt"
|
||||
ktfmt().dropboxStyle()
|
||||
licenseHeaderFile("NOTICE")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preDebugBuild.dependsOn spotlessApply
|
||||
// Fuzzy search
|
||||
implementation 'org.apache.commons:commons-text:1.9'
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="info_app_name" translatable="false">Auxio Debug</string>
|
||||
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
|
||||
</resources>
|
|
@ -2,9 +2,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
@ -48,6 +45,7 @@
|
|||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:launchMode="singleTask"
|
||||
android:allowCrossUidActivitySwitchFromBelow="false"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
|
||||
|
@ -92,13 +90,22 @@
|
|||
android:foregroundServiceType="mediaPlayback"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:roundIcon="@mipmap/ic_launcher">
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Expose Auxio's cover data to the android system
|
||||
-->
|
||||
<provider
|
||||
android:name=".image.CoverProvider"
|
||||
android:authorities="@string/pkg_authority_cover"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<!--
|
||||
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||
See the class for more info.
|
||||
|
@ -135,5 +142,15 @@
|
|||
android:resource="@xml/widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Tasker 'start service' integration -->
|
||||
<activity
|
||||
android:name=".tasker.ActivityConfigStartAction"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/lbl_start_playback">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
|
@ -1309,7 +1309,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
+ " should not be set externally.");
|
||||
}
|
||||
if (!hideable && state == STATE_HIDDEN) {
|
||||
Log.w(TAG, "Cannot set state: " + state);
|
||||
return;
|
||||
}
|
||||
final int finalState;
|
||||
|
@ -1390,6 +1389,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
return shouldRemoveExpandedCorners;
|
||||
}
|
||||
|
||||
public void killCorners() {
|
||||
materialShapeDrawable.setCornerSize(0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current state of the bottom sheet.
|
||||
*
|
||||
|
@ -1629,12 +1632,13 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
return;
|
||||
}
|
||||
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
|
||||
boolean canActuallyHide = hideable && isHideableWhenDragging();
|
||||
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
|
||||
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
return;
|
||||
}
|
||||
if (hideable && isHideableWhenDragging()) {
|
||||
if (canActuallyHide) {
|
||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||
backEvent,
|
||||
new AnimatorListenerAdapter() {
|
||||
|
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings
|
|||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.CopyleftNoticeTree
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -45,7 +46,11 @@ class Auxio : Application() {
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
|
||||
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
|
||||
Timber.plant(CopyleftNoticeTree())
|
||||
} else if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
|
|
|
@ -19,91 +19,154 @@
|
|||
package org.oxycblt.auxio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
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.music.service.MusicServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService : MediaLibraryService(), ForegroundListener {
|
||||
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
|
||||
class AuxioService :
|
||||
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||
|
||||
@Inject lateinit var indexingFragment: IndexerServiceFragment
|
||||
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||
private lateinit var musicFragment: MusicServiceFragment
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mediaSessionFragment.attach(this, this)
|
||||
indexingFragment.attach(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
handleIntent(intent)
|
||||
return super.onBind(intent)
|
||||
playbackFragment = playbackFragmentFactory.create(this, this)
|
||||
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||
sessionToken = playbackFragment.attach()
|
||||
musicFragment.attach()
|
||||
Timber.d("Service Created")
|
||||
}
|
||||
|
||||
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)
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
onHandleForeground(intent)
|
||||
// If we die we want to not restart, we will immediately try to foreground in and just
|
||||
// fail to start again since the activity will be dead too. This is not the semantically
|
||||
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
|
||||
// weird foreground errors.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
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 onBind(intent: Intent): IBinder? {
|
||||
val binder = super.onBind(intent)
|
||||
onHandleForeground(intent)
|
||||
return binder
|
||||
}
|
||||
|
||||
private fun onHandleForeground(intent: Intent?) {
|
||||
musicFragment.start()
|
||||
playbackFragment.start(intent)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
mediaSessionFragment.handleTaskRemoved()
|
||||
playbackFragment.handleTaskRemoved()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
indexingFragment.release()
|
||||
mediaSessionFragment.release()
|
||||
musicFragment.release()
|
||||
playbackFragment.release()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
||||
mediaSessionFragment.mediaSession
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot {
|
||||
return musicFragment.getRoot()
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
|
||||
musicFragment.getItem(itemId, result)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
|
||||
val maximumRootChildLimit = getRootChildrenLimit()
|
||||
musicFragment.getChildren(parentId, maximumRootChildLimit, result, null)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<MutableList<MediaItem>>,
|
||||
options: Bundle
|
||||
) {
|
||||
val maximumRootChildLimit = getRootChildrenLimit()
|
||||
musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage())
|
||||
}
|
||||
|
||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {
|
||||
musicFragment.search(query, result, extras?.getPage())
|
||||
}
|
||||
|
||||
private fun getRootChildrenLimit(): Int {
|
||||
return browserRootHints?.getInt(
|
||||
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4
|
||||
}
|
||||
|
||||
private fun Bundle.getPage(): MusicServiceFragment.Page? {
|
||||
val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null
|
||||
val pageSize =
|
||||
getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null
|
||||
return MusicServiceFragment.Page(page, pageSize)
|
||||
}
|
||||
|
||||
override fun updateForeground(change: ForegroundListener.Change) {
|
||||
if (mediaSessionFragment.hasNotification()) {
|
||||
val mediaNotification = playbackFragment.notification
|
||||
if (mediaNotification != null) {
|
||||
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||
mediaSessionFragment.createNotification {
|
||||
startForeground(it.notificationId, it.notification)
|
||||
}
|
||||
startForeground(mediaNotification.code, mediaNotification.build())
|
||||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
isForeground = true
|
||||
} else {
|
||||
indexingFragment.createNotification {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
startForeground(it.code, it.build())
|
||||
isForeground = true
|
||||
} else {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
isForeground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidateMusic(mediaId: String) {
|
||||
notifyChildrenChanged(mediaId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = BuildConfig.APPLICATION_ID + ".service.START"
|
||||
|
||||
var isForeground = false
|
||||
private set
|
||||
|
||||
// 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"
|
||||
const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,3 +178,42 @@ interface ForegroundListener {
|
|||
INDEXER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
|
|
@ -49,8 +49,10 @@ object IntegerTable {
|
|||
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
|
||||
/** EditHeaderViewHolder */
|
||||
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
|
||||
const val VIEW_TYPE_EDIT_HEADER = 0xA00D
|
||||
/** PlaylistSongViewHolder */
|
||||
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
|
||||
/** "Music playback" notification code */
|
||||
|
@ -59,6 +61,12 @@ object IntegerTable {
|
|||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||
/** MainActivity Intent request code */
|
||||
const val REQUEST_CODE = 0xA0C0
|
||||
/** Activity AuxioService Start ID */
|
||||
const val START_ID_ACTIVITY = 0xA050
|
||||
/** Tasker AuxioService Start ID */
|
||||
const val START_ID_TASKER = 0xA051
|
||||
/** MediaButtonReceiver AuxioService Start ID */
|
||||
const val START_ID_MEDIA_BUTTON = 0xA052
|
||||
/** RepeatMode.NONE */
|
||||
const val REPEAT_MODE_NONE = 0xA100
|
||||
/** RepeatMode.ALL */
|
||||
|
@ -117,10 +125,10 @@ object IntegerTable {
|
|||
const val ACTION_MODE_SHUFFLE = 0xA11B
|
||||
/** CoverMode.Off */
|
||||
const val COVER_MODE_OFF = 0xA11C
|
||||
/** CoverMode.MediaStore */
|
||||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
||||
/** CoverMode.Balanced */
|
||||
const val COVER_MODE_BALANCED = 0xA11D
|
||||
/** CoverMode.Quality */
|
||||
const val COVER_MODE_QUALITY = 0xA11E
|
||||
const val COVER_MODE_HIGH_QUALITY = 0xA11E
|
||||
/** PlaySong.FromAll */
|
||||
const val PLAY_SONG_FROM_ALL = 0xA11F
|
||||
/** PlaySong.FromAlbum */
|
||||
|
@ -133,7 +141,8 @@ object IntegerTable {
|
|||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||
/** PlaySong.ByItself */
|
||||
const val PLAY_SONG_BY_ITSELF = 0xA124
|
||||
const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125
|
||||
const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126
|
||||
const val PLAYER_COMMAND_EXIT = 0xA127
|
||||
/** CoverMode.SaveSpace */
|
||||
const val COVER_MODE_SAVE_SPACE = 0xA125
|
||||
/** CoverMode.AsIs */
|
||||
const val COVER_MODE_AS_IS = 0xA126
|
||||
}
|
||||
|
|
|
@ -33,9 +33,8 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* Auxio's single [AppCompatActivity].
|
||||
|
@ -63,7 +62,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupEdgeToEdge(binding.root)
|
||||
logD("Activity created")
|
||||
L.d("Activity created")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -71,11 +70,12 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
startService(
|
||||
Intent(this, AuxioService::class.java)
|
||||
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
|
||||
.setAction(AuxioService.ACTION_START)
|
||||
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
playbackModel.playDeferred(DeferredPlayback.RestoreState)
|
||||
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,10 +90,10 @@ class MainActivity : AppCompatActivity() {
|
|||
// Apply the color scheme. The black theme requires it's own set of themes since
|
||||
// it's not possible to modify the themes at run-time.
|
||||
if (isNight && uiSettings.useBlackTheme) {
|
||||
logD("Applying black theme [accent ${uiSettings.accent}]")
|
||||
L.d("Applying black theme [accent ${uiSettings.accent}]")
|
||||
setTheme(uiSettings.accent.blackTheme)
|
||||
} else {
|
||||
logD("Applying normal theme [accent ${uiSettings.accent}]")
|
||||
L.d("Applying normal theme [accent ${uiSettings.accent}]")
|
||||
setTheme(uiSettings.accent.theme)
|
||||
}
|
||||
}
|
||||
|
@ -120,7 +120,7 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
// Nothing to do.
|
||||
logD("No intent to handle")
|
||||
L.d("No intent to handle")
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// This is because onStart can run multiple times, and thus we really don't
|
||||
// want to return false and override the original delayed action with a
|
||||
// RestoreState action.
|
||||
logD("Already used this intent")
|
||||
L.d("Already used this intent")
|
||||
return true
|
||||
}
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
|
@ -139,11 +139,11 @@ class MainActivity : AppCompatActivity() {
|
|||
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
|
||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
|
||||
else -> {
|
||||
logW("Unexpected intent ${intent.action}")
|
||||
L.w("Unexpected intent ${intent.action}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
logD("Translated intent to $action")
|
||||
L.d("Translated intent to $action")
|
||||
playbackModel.playDeferred(action)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ package org.oxycblt.auxio
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowInsets
|
||||
import androidx.activity.BackEventCompat
|
||||
|
@ -42,6 +40,7 @@ import com.leinardi.android.speeddial.SpeedDialActionItem
|
|||
import com.leinardi.android.speeddial.SpeedDialView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Method
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
|
@ -51,15 +50,14 @@ import org.oxycblt.auxio.home.HomeViewModel
|
|||
import org.oxycblt.auxio.home.Outer
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.OpenPanel
|
||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
|
@ -67,11 +65,12 @@ import org.oxycblt.auxio.util.context
|
|||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
|
||||
|
@ -98,6 +97,7 @@ class MainFragment :
|
|||
private var normalCornerSize = 0f
|
||||
private var maxScaleXDistance = 0f
|
||||
private var sheetRising: Boolean? = null
|
||||
@Inject lateinit var uiSettings: UISettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -112,8 +112,11 @@ class MainFragment :
|
|||
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
playbackSheetBehavior.uiSettings = uiSettings
|
||||
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
queueSheetBehavior?.uiSettings = uiSettings
|
||||
|
||||
elevationNormal = binding.context.getDimen(MR.dimen.m3_sys_elevation_level1)
|
||||
|
||||
|
@ -128,7 +131,7 @@ class MainFragment :
|
|||
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||
val selectionBackCallback =
|
||||
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
||||
speedDialBackCallback = SpeedDialBackPressedCallback(homeModel)
|
||||
speedDialBackCallback = SpeedDialBackPressedCallback()
|
||||
|
||||
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
|
||||
|
||||
|
@ -148,13 +151,13 @@ class MainFragment :
|
|||
|
||||
if (queueSheetBehavior != null) {
|
||||
// In portrait mode, set up click listeners on the stacked sheets.
|
||||
logD("Configuring stacked bottom sheets")
|
||||
L.d("Configuring stacked bottom sheets")
|
||||
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
|
||||
playbackModel.openQueue()
|
||||
}
|
||||
} else {
|
||||
// Dual-pane mode, manually style the static queue sheet.
|
||||
logD("Configuring dual-pane bottom sheet")
|
||||
L.d("Configuring dual-pane bottom sheet")
|
||||
binding.queueSheet.apply {
|
||||
// Emulate the elevated bottom sheet style.
|
||||
background =
|
||||
|
@ -232,17 +235,6 @@ class MainFragment :
|
|||
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
|
||||
}
|
||||
|
||||
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
|
||||
// it ourselves.
|
||||
requireBinding().root.rootView.apply {
|
||||
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
|
||||
handleSpeedDialBoundaryTouch(event)
|
||||
}
|
||||
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
|
||||
handleSpeedDialBoundaryTouch(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -265,9 +257,9 @@ class MainFragment :
|
|||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
|
||||
// sheets continually getting stuck. I need something with even more frequent updates,
|
||||
// or otherwise bottom sheets get stuck.
|
||||
// This is where I shove literally all the UI logic that won't behave any callback
|
||||
// or "normal" method I've tried. Surely running this on every frame will actually cause
|
||||
// it to work properly!
|
||||
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
|
@ -375,17 +367,21 @@ class MainFragment :
|
|||
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
||||
.invalidateEnabled()
|
||||
|
||||
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
|
||||
binding.mainFabContainer.isVisible =
|
||||
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
|
||||
when (actionItem.id) {
|
||||
R.id.action_new_playlist -> {
|
||||
logD("Creating playlist")
|
||||
L.d("Creating playlist")
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
R.id.action_import_playlist -> {
|
||||
logD("Importing playlist")
|
||||
L.d("Importing playlist")
|
||||
musicModel.importPlaylist()
|
||||
}
|
||||
else -> {}
|
||||
|
@ -412,11 +408,8 @@ class MainFragment :
|
|||
}
|
||||
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
// TODO: Make music loading experience a bit more pleasant
|
||||
// 1. Loading placeholder for item lists
|
||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||
if (state is IndexingState.Completed && state.error == null) {
|
||||
logD("Received ok response")
|
||||
L.d("Received ok response")
|
||||
val binding = requireBinding()
|
||||
updateFabVisibility(
|
||||
binding,
|
||||
|
@ -441,7 +434,7 @@ class MainFragment :
|
|||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||
if (shouldHideAllFabs(binding, songs, isFastScrolling)) {
|
||||
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||
L.d("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||
forceHideAllFabs()
|
||||
} else {
|
||||
if (tabType != MusicType.PLAYLISTS) {
|
||||
|
@ -450,7 +443,7 @@ class MainFragment :
|
|||
}
|
||||
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
logD("Animating transition")
|
||||
L.d("Animating transition")
|
||||
binding.homeNewPlaylistFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
|
@ -465,17 +458,17 @@ class MainFragment :
|
|||
}
|
||||
})
|
||||
} else {
|
||||
logD("Showing immediately")
|
||||
L.d("Showing immediately")
|
||||
binding.homeShuffleFab.show()
|
||||
}
|
||||
} else {
|
||||
logD("Showing playlist button")
|
||||
L.d("Showing playlist button")
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
return
|
||||
}
|
||||
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
logD("Animating transition")
|
||||
L.d("Animating transition")
|
||||
binding.homeShuffleFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
|
@ -490,7 +483,7 @@ class MainFragment :
|
|||
}
|
||||
})
|
||||
} else {
|
||||
logD("Showing immediately")
|
||||
L.d("Showing immediately")
|
||||
binding.homeNewPlaylistFab.show()
|
||||
}
|
||||
}
|
||||
|
@ -524,45 +517,8 @@ class MainFragment :
|
|||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||
.invalidateEnabled(open)
|
||||
val binding = requireBinding()
|
||||
logD(open)
|
||||
binding.mainScrim.isVisible = open
|
||||
binding.sheetScrim.isVisible = open
|
||||
if (open) {
|
||||
binding.homeNewPlaylistFab.open(true)
|
||||
} else {
|
||||
binding.homeNewPlaylistFab.close(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
|
||||
val binding = binding ?: return false
|
||||
|
||||
if (binding.homeNewPlaylistFab.isOpen &&
|
||||
binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
|
||||
// Convert absolute coordinates to relative coordinates
|
||||
val offsetX = event.x - binding.homeNewPlaylistFab.x
|
||||
val offsetY = event.y - binding.homeNewPlaylistFab.y
|
||||
|
||||
// Create a new MotionEvent with relative coordinates
|
||||
val relativeEvent =
|
||||
MotionEvent.obtain(
|
||||
event.downTime,
|
||||
event.eventTime,
|
||||
event.action,
|
||||
offsetX,
|
||||
offsetY,
|
||||
event.metaState)
|
||||
|
||||
// Dispatch the relative MotionEvent to the target child view
|
||||
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
|
||||
|
||||
// Recycle the relative MotionEvent
|
||||
relativeEvent.recycle()
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
return false
|
||||
binding.mainScrim.isInvisible = !open
|
||||
binding.sheetScrim.isInvisible = !open
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
|
@ -600,7 +556,7 @@ class MainFragment :
|
|||
|
||||
private fun handlePanel(panel: OpenPanel?) {
|
||||
if (panel == null) return
|
||||
logD("Trying to update panel to $panel")
|
||||
L.d("Trying to update panel to $panel")
|
||||
when (panel) {
|
||||
OpenPanel.MAIN -> tryClosePlaybackPanel()
|
||||
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
|
||||
|
@ -616,7 +572,7 @@ class MainFragment :
|
|||
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||
logD("Expanding playback sheet")
|
||||
L.d("Expanding playback sheet")
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||
return
|
||||
}
|
||||
|
@ -627,7 +583,7 @@ class MainFragment :
|
|||
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
||||
// playback panel can shown.
|
||||
logD("Collapsing queue sheet")
|
||||
L.d("Collapsing queue sheet")
|
||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
@ -638,7 +594,7 @@ class MainFragment :
|
|||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Playback sheet (and possibly queue) needs to be collapsed.
|
||||
logD("Collapsing playback and queue sheets")
|
||||
L.d("Collapsing playback and queue sheets")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
|
@ -664,7 +620,7 @@ class MainFragment :
|
|||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
logD("Unhiding and enabling playback sheet")
|
||||
L.d("Unhiding and enabling playback sheet")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||
|
@ -685,7 +641,7 @@ class MainFragment :
|
|||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
logD("Hiding and disabling playback and queue sheets")
|
||||
L.d("Hiding and disabling playback and queue sheets")
|
||||
|
||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||
queueSheetBehavior?.apply {
|
||||
|
@ -769,7 +725,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (detailModel.dropPlaylistEdit()) {
|
||||
logD("Dropped playlist edits")
|
||||
L.d("Dropped playlist edits")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -782,7 +738,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (listModel.dropSelection()) {
|
||||
logD("Dropped selection")
|
||||
L.d("Dropped selection")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -791,8 +747,7 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
|
||||
OnBackPressedCallback(false) {
|
||||
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeNewPlaylistFab.isOpen) {
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.os.Bundle
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
|
@ -29,22 +30,23 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information about an [Album].
|
||||
|
@ -90,7 +92,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(R.menu.album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song) {
|
||||
|
@ -103,7 +105,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
logD("No album to show, navigating away")
|
||||
L.d("No album to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -115,7 +117,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
binding.detailToolbarTitle.text = name
|
||||
binding.detailCover.bind(album)
|
||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||
binding.detailType.text = getString(album.releaseType.stringRes)
|
||||
binding.detailType.text = album.releaseType.resolve(context)
|
||||
binding.detailName.text = name
|
||||
// Artist name maps to the subhead text
|
||||
binding.detailSubhead.apply {
|
||||
|
@ -131,7 +133,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
|
||||
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||
val duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
|
@ -140,9 +142,17 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -153,7 +163,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val binding = requireBinding()
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
logD("Navigating to ${show.song}")
|
||||
L.d("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
@ -162,11 +172,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
|
||||
logD("Navigating to a ${show.song} in this album")
|
||||
L.d("Navigating to a ${show.song} in this album")
|
||||
scrollToAlbumSong(show.song)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -176,27 +186,27 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
|
||||
logD("Navigating to the top of this album")
|
||||
L.d("Navigating to the top of this album")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
logD("Navigating to ${show.album}")
|
||||
L.d("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
L.d("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
|
@ -239,7 +249,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
AlbumDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -268,11 +278,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
@ -289,6 +299,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
|
||||
// collapsed appbar), so we need to collapse the appbar if that's the case.
|
||||
binding.detailAppbar.setExpanded(false)
|
||||
if (!binding.detailRecycler.canScroll()) {
|
||||
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
|
||||
// kicks in and creates a weird bounce effect.
|
||||
return
|
||||
}
|
||||
binding.detailRecycler.post {
|
||||
// Use a custom smooth scroller that will settle the item in the middle of
|
||||
// the screen rather than the end.
|
||||
|
@ -314,4 +329,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
||||
}
|
||||
|
|
|
@ -29,23 +29,23 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information about an [Artist].
|
||||
|
@ -112,7 +112,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
logD("No artist to show, navigating away")
|
||||
L.d("No artist to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -155,7 +155,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
// The artist does not have any songs, so hide functionality that makes no sense.
|
||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||
logD("Artist is empty, disabling genres and playback")
|
||||
L.d("Artist is empty, disabling genres and playback")
|
||||
binding.detailSubhead.isVisible = false
|
||||
binding.detailPlayButton?.isEnabled = false
|
||||
binding.detailShuffleButton?.isEnabled = false
|
||||
|
@ -164,9 +164,17 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -177,14 +185,14 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
val binding = requireBinding()
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
logD("Navigating to ${show.song}")
|
||||
L.d("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
||||
// Songs should be shown in their album, not in their artist.
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -192,7 +200,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
// Launch a new detail view for an album, even if it is part of
|
||||
// this artist.
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
L.d("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
@ -201,22 +209,22 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
// scroll back to the top. Otherwise launch a new detail view.
|
||||
is Show.ArtistDetails -> {
|
||||
if (show.artist == detailModel.currentArtist.value) {
|
||||
logD("Navigating to the top of this artist")
|
||||
L.d("Navigating to the top of this artist")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
logD("Navigating to ${show.artist}")
|
||||
L.d("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
|
@ -260,7 +268,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
ArtistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -301,7 +309,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
is PlaybackDecision.PlayFromArtist ->
|
||||
error("Unexpected playback decision $decision")
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* ContinuousAppBarLayoutBehavior.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
|
||||
class ContinuousAppBarLayoutBehavior
|
||||
@JvmOverloads
|
||||
constructor(context: Context? = null, attrs: AttributeSet? = null) :
|
||||
AppBarLayout.Behavior(context, attrs) {
|
||||
private var recycler: RecyclerView? = null
|
||||
private var pointerId = -1
|
||||
private var velocityTracker: VelocityTracker? = null
|
||||
|
||||
override fun onInterceptTouchEvent(
|
||||
parent: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
ev: MotionEvent
|
||||
): Boolean {
|
||||
val consumed = super.onInterceptTouchEvent(parent, child, ev)
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
ensureVelocityTracker()
|
||||
findRecyclerView(child).stopScroll()
|
||||
pointerId = ev.getPointerId(0)
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
pointerId = -1
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
|
||||
override fun onTouchEvent(
|
||||
parent: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
ev: MotionEvent
|
||||
): Boolean {
|
||||
val consumed = super.onTouchEvent(parent, child, ev)
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
ensureVelocityTracker()
|
||||
pointerId = ev.getPointerId(0)
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
findRecyclerView(child).fling(0, getYVelocity(ev))
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
pointerId = -1
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
velocityTracker?.addMovement(ev)
|
||||
return consumed
|
||||
}
|
||||
|
||||
private fun ensureVelocityTracker() {
|
||||
if (velocityTracker == null) {
|
||||
velocityTracker = VelocityTracker.obtain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getYVelocity(event: MotionEvent): Int {
|
||||
velocityTracker?.let {
|
||||
it.addMovement(event)
|
||||
it.computeCurrentVelocity(FLING_UNITS)
|
||||
return -it.getYVelocity(pointerId).toInt()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun findRecyclerView(child: AppBarLayout): RecyclerView {
|
||||
val recycler = recycler
|
||||
if (recycler != null) {
|
||||
return recycler
|
||||
}
|
||||
|
||||
// Use the scrolling view in order to find a RecyclerView to use.
|
||||
val newRecycler =
|
||||
(child.parent as ViewGroup).findViewById<RecyclerView>(child.liftOnScrollTargetViewId)
|
||||
this.recycler = newRecycler
|
||||
return newRecycler
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLING_UNITS = 1000 // copied from base class
|
||||
}
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* DetailAppBarLayout.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import java.lang.reflect.Field
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||
* view goes beyond it's first item.
|
||||
*
|
||||
* This is intended for the detail views, in which the first item is the album/artist/genre header,
|
||||
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||
* where the user currently is.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DetailAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var titleView: TextView? = null
|
||||
private var recycler: RecyclerView? = null
|
||||
|
||||
private var titleShown: Boolean? = null
|
||||
private var titleAnimator: ValueAnimator? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (!isInEditMode) {
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findTitleView(): TextView {
|
||||
val titleView = titleView
|
||||
if (titleView != null) {
|
||||
return titleView
|
||||
}
|
||||
|
||||
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
|
||||
// used within the detail layouts.
|
||||
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
|
||||
|
||||
// The Toolbar's title view is actually hidden. To avoid having to create our own
|
||||
// title view, we just reflect into Toolbar and grab the hidden field.
|
||||
val newTitleView =
|
||||
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
||||
// We can never properly initialize the title view's state before draw time,
|
||||
// so we just set it's alpha to 0f to produce a less jarring initialization
|
||||
// animation.
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
this.titleView = newTitleView
|
||||
return newTitleView
|
||||
}
|
||||
|
||||
private fun findRecyclerView(): RecyclerView {
|
||||
val recycler = recycler
|
||||
if (recycler != null) {
|
||||
return recycler
|
||||
}
|
||||
|
||||
// Use the scrolling view in order to find a RecyclerView to use.
|
||||
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
|
||||
this.recycler = newRecycler
|
||||
return newRecycler
|
||||
}
|
||||
|
||||
private fun setTitleVisibility(visible: Boolean) {
|
||||
if (titleShown == visible) return
|
||||
titleShown = visible
|
||||
|
||||
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
||||
// the title view's alpha instead of the AppBarLayout's elevation.
|
||||
val titleView = findTitleView()
|
||||
val from: Float
|
||||
val to: Float
|
||||
|
||||
if (visible) {
|
||||
from = 0f
|
||||
to = 1f
|
||||
} else {
|
||||
from = 1f
|
||||
to = 0f
|
||||
}
|
||||
|
||||
if (titleView.alpha == to) {
|
||||
// Nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
logD("Changing title visibility [from: $from to: $to]")
|
||||
titleAnimator?.cancel()
|
||||
titleAnimator =
|
||||
ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
||||
duration =
|
||||
if (titleShown == true) {
|
||||
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
class Behavior
|
||||
@JvmOverloads
|
||||
constructor(context: Context? = null, attrs: AttributeSet? = null) :
|
||||
AppBarLayout.Behavior(context, attrs) {
|
||||
override fun onNestedPreScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
target: View,
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
consumed: IntArray,
|
||||
type: Int
|
||||
) {
|
||||
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
|
||||
|
||||
val appBarLayout = child as DetailAppBarLayout
|
||||
val recycler = appBarLayout.findRecyclerView()
|
||||
|
||||
// Title should be visible if we are no longer showing the top item
|
||||
// (i.e the header)
|
||||
appBarLayout.setTitleVisibility(
|
||||
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||
}
|
||||
}
|
|
@ -31,17 +31,17 @@ import kotlin.math.min
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
|
||||
abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||
ListFragment<C, FragmentDetailBinding>(),
|
||||
|
@ -91,7 +91,7 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
|
|||
detailModel.artistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
item is PlainDivider || item is PlainHeader
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
@ -115,14 +115,17 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
|
|||
|
||||
val outRatio = min(ratio * 2, 1f)
|
||||
val detailHeader = binding.detailHeader
|
||||
detailHeader.scaleX = 1 - 0.05f * outRatio
|
||||
detailHeader.scaleY = 1 - 0.05f * outRatio
|
||||
detailHeader.scaleX = 1 - 0.2f * outRatio / (5f / 3f)
|
||||
detailHeader.scaleY = 1 - 0.2f * outRatio / (5f / 3f)
|
||||
detailHeader.alpha = 1 - outRatio
|
||||
|
||||
val inRatio = max(ratio - 0.5f, 0f) * 2
|
||||
val detailContent = binding.detailToolbarContent
|
||||
detailContent.alpha = inRatio
|
||||
detailContent.translationY = spacingSmall * (1 - inRatio)
|
||||
|
||||
// Enable fast scrolling once fully collapsed
|
||||
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
|
||||
}
|
||||
|
||||
abstract fun onOpenParentMenu()
|
||||
|
|
241
app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt
Normal file
241
app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt
Normal file
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DetailGenerator.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.tag.Disc
|
||||
import org.oxycblt.musikr.tag.ReleaseType
|
||||
import timber.log.Timber as L
|
||||
|
||||
interface DetailGenerator {
|
||||
fun any(uid: Music.UID): Detail<out MusicParent>?
|
||||
|
||||
fun album(uid: Music.UID): Detail<Album>?
|
||||
|
||||
fun artist(uid: Music.UID): Detail<Artist>?
|
||||
|
||||
fun genre(uid: Music.UID): Detail<Genre>?
|
||||
|
||||
fun playlist(uid: Music.UID): Detail<Playlist>?
|
||||
|
||||
fun attach()
|
||||
|
||||
fun release()
|
||||
|
||||
interface Factory {
|
||||
fun create(invalidator: Invalidator): DetailGenerator
|
||||
}
|
||||
|
||||
interface Invalidator {
|
||||
fun invalidate(type: MusicType, replace: Int?)
|
||||
}
|
||||
}
|
||||
|
||||
class DetailGeneratorFactoryImpl
|
||||
@Inject
|
||||
constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) :
|
||||
DetailGenerator.Factory {
|
||||
override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator =
|
||||
DetailGeneratorImpl(invalidator, listSettings, musicRepository)
|
||||
}
|
||||
|
||||
private class DetailGeneratorImpl(
|
||||
private val invalidator: DetailGenerator.Invalidator,
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository
|
||||
) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener {
|
||||
override fun attach() {
|
||||
listSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onAlbumSongSortChanged() {
|
||||
super.onAlbumSongSortChanged()
|
||||
invalidator.invalidate(MusicType.ALBUMS, -1)
|
||||
}
|
||||
|
||||
override fun onArtistSongSortChanged() {
|
||||
super.onArtistSongSortChanged()
|
||||
invalidator.invalidate(MusicType.ARTISTS, -1)
|
||||
}
|
||||
|
||||
override fun onGenreSongSortChanged() {
|
||||
super.onGenreSongSortChanged()
|
||||
invalidator.invalidate(MusicType.GENRES, -1)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.deviceLibrary) {
|
||||
invalidator.invalidate(MusicType.ALBUMS, null)
|
||||
invalidator.invalidate(MusicType.ARTISTS, null)
|
||||
invalidator.invalidate(MusicType.GENRES, null)
|
||||
}
|
||||
if (changes.userLibrary) {
|
||||
invalidator.invalidate(MusicType.PLAYLISTS, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
listSettings.unregisterListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun any(uid: Music.UID): Detail<out MusicParent>? {
|
||||
val music = musicRepository.find(uid) ?: return null
|
||||
return when (music) {
|
||||
is Album -> album(uid)
|
||||
is Artist -> artist(uid)
|
||||
is Genre -> genre(uid)
|
||||
is Playlist -> playlist(uid)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun album(uid: Music.UID): Detail<Album>? {
|
||||
val album = musicRepository.library?.findAlbum(uid) ?: return null
|
||||
val songs = listSettings.albumSongSort.songs(album.songs)
|
||||
val discs = songs.groupBy { it.disc }
|
||||
val section =
|
||||
if (discs.size > 1) {
|
||||
DetailSection.Discs(discs)
|
||||
} else {
|
||||
DetailSection.Songs(songs)
|
||||
}
|
||||
return Detail(album, listOf(section))
|
||||
}
|
||||
|
||||
override fun artist(uid: Music.UID): Detail<Artist>? {
|
||||
val artist = musicRepository.library?.findArtist(uid) ?: return null
|
||||
val grouping =
|
||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||
// Remap the complicated ReleaseType data structure into detail sections
|
||||
when (it.releaseType.refinement) {
|
||||
ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE
|
||||
ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES
|
||||
null ->
|
||||
when (it.releaseType) {
|
||||
is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS
|
||||
is ReleaseType.EP -> DetailSection.Albums.Category.EPS
|
||||
is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES
|
||||
is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS
|
||||
is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS
|
||||
is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES
|
||||
is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES
|
||||
is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artist.implicitAlbums.isNotEmpty()) {
|
||||
L.d("Implicit albums present, adding to list")
|
||||
grouping[DetailSection.Albums.Category.APPEARANCES] =
|
||||
artist.implicitAlbums.toMutableList()
|
||||
}
|
||||
|
||||
val sections =
|
||||
grouping.mapTo(mutableListOf<DetailSection>()) { (category, albums) ->
|
||||
DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums))
|
||||
}
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs))
|
||||
sections.add(songs)
|
||||
}
|
||||
return Detail(artist, sections)
|
||||
}
|
||||
|
||||
override fun genre(uid: Music.UID): Detail<Genre>? {
|
||||
val genre = musicRepository.library?.findGenre(uid) ?: return null
|
||||
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
|
||||
return Detail(genre, listOf(artists, songs))
|
||||
}
|
||||
|
||||
override fun playlist(uid: Music.UID): Detail<Playlist>? {
|
||||
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
|
||||
if (playlist.songs.isNotEmpty()) {
|
||||
val songs = DetailSection.Songs(playlist.songs)
|
||||
return Detail(playlist, listOf(songs))
|
||||
}
|
||||
return Detail(playlist, listOf())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
data class Detail<P : MusicParent>(val parent: P, val sections: List<DetailSection>)
|
||||
|
||||
sealed interface DetailSection {
|
||||
val order: Int
|
||||
val stringRes: Int
|
||||
|
||||
abstract class PlainSection<T : Music> : DetailSection {
|
||||
abstract val items: List<T>
|
||||
}
|
||||
|
||||
data class Artists(override val items: List<Artist>) : PlainSection<Artist>() {
|
||||
override val order = 0
|
||||
override val stringRes = R.string.lbl_artists
|
||||
}
|
||||
|
||||
data class Albums(val category: Category, override val items: List<Album>) :
|
||||
PlainSection<Album>() {
|
||||
override val order = 1 + category.ordinal
|
||||
override val stringRes = category.stringRes
|
||||
|
||||
enum class Category(@StringRes val stringRes: Int) {
|
||||
ALBUMS(R.string.lbl_albums),
|
||||
EPS(R.string.lbl_eps),
|
||||
SINGLES(R.string.lbl_singles),
|
||||
COMPILATIONS(R.string.lbl_compilations),
|
||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||
DJ_MIXES(R.string.lbl_mixes),
|
||||
MIXTAPES(R.string.lbl_mixtapes),
|
||||
DEMOS(R.string.lbl_demos),
|
||||
APPEARANCES(R.string.lbl_appears_on),
|
||||
LIVE(R.string.lbl_live_group),
|
||||
REMIXES(R.string.lbl_remix_group)
|
||||
}
|
||||
}
|
||||
|
||||
data class Songs(override val items: List<Song>) : PlainSection<Song>() {
|
||||
override val order = 12
|
||||
override val stringRes = R.string.lbl_songs
|
||||
}
|
||||
|
||||
data class Discs(val discs: Map<Disc?, List<Song>>) : DetailSection {
|
||||
override val order = 13
|
||||
override val stringRes = R.string.lbl_songs
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ExternalModule.kt is part of Auxio.
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DetailModule.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,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.external
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
@ -25,11 +25,6 @@ import dagger.hilt.components.SingletonComponent
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ExternalModule {
|
||||
@Binds
|
||||
fun externalPlaylistManager(
|
||||
externalPlaylistManager: ExternalPlaylistManagerImpl
|
||||
): ExternalPlaylistManager
|
||||
|
||||
@Binds fun m3u(m3u: M3UImpl): M3U
|
||||
interface DetailModule {
|
||||
@Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory
|
||||
}
|
|
@ -18,43 +18,41 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.list.DiscDivider
|
||||
import org.oxycblt.auxio.detail.list.DiscHeader
|
||||
import org.oxycblt.auxio.detail.list.EditHeader
|
||||
import org.oxycblt.auxio.detail.list.SongProperty
|
||||
import org.oxycblt.auxio.detail.list.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
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
|
||||
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.info.ReleaseType
|
||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||
|
@ -68,10 +66,11 @@ class DetailViewModel
|
|||
constructor(
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||
private val playbackSettings: PlaybackSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
detailGeneratorFactory: DetailGenerator.Factory
|
||||
) : ViewModel(), DetailGenerator.Invalidator {
|
||||
private val _toShow = MutableEvent<Show>()
|
||||
|
||||
/**
|
||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
*/
|
||||
|
@ -80,30 +79,34 @@ constructor(
|
|||
|
||||
// --- SONG ---
|
||||
|
||||
private var currentSongJob: Job? = null
|
||||
|
||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||
|
||||
/** The current [Song] to display. Null if there is nothing to show. */
|
||||
val currentSong: StateFlow<Song?>
|
||||
get() = _currentSong
|
||||
|
||||
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
|
||||
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
|
||||
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
|
||||
|
||||
/** The current properties of [currentSong]. Empty if nothing to show. */
|
||||
val currentSongProperties: StateFlow<List<SongProperty>>
|
||||
get() = _currentSongProperties
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
|
||||
/** The current [Album] to display. Null if there is nothing to show. */
|
||||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentAlbum]. */
|
||||
val albumSongList: StateFlow<List<Item>>
|
||||
get() = _albumSongList
|
||||
|
||||
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [albumSongList] in the UI. */
|
||||
val albumSongInstructions: Event<UpdateInstructions>
|
||||
get() = _albumSongInstructions
|
||||
|
@ -119,27 +122,25 @@ constructor(
|
|||
// --- ARTIST ---
|
||||
|
||||
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
||||
|
||||
/** The current [Artist] to display. Null if there is nothing to show. */
|
||||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list derived from [currentArtist]. */
|
||||
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
||||
|
||||
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val artistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _artistSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
||||
var artistSongSort: Sort
|
||||
val artistSongSort: Sort
|
||||
get() = listSettings.artistSongSort
|
||||
set(value) {
|
||||
listSettings.artistSongSort = value
|
||||
// Refresh the artist list to reflect the new sort.
|
||||
currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
||||
val playInArtistWith
|
||||
|
@ -148,27 +149,25 @@ constructor(
|
|||
// --- GENRE ---
|
||||
|
||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||
|
||||
/** The current [Genre] to display. Null if there is nothing to show. */
|
||||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentGenre]. */
|
||||
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
||||
|
||||
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val genreSongInstructions: Event<UpdateInstructions>
|
||||
get() = _genreSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
||||
var genreSongSort: Sort
|
||||
val genreSongSort: Sort
|
||||
get() = listSettings.genreSongSort
|
||||
set(value) {
|
||||
listSettings.genreSongSort = value
|
||||
// Refresh the genre list to reflect the new sort.
|
||||
currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||
val playInGenreWith
|
||||
|
@ -177,20 +176,24 @@ constructor(
|
|||
// --- PLAYLIST ---
|
||||
|
||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||
|
||||
/** The current [Playlist] to display. Null if there is nothing to do. */
|
||||
val currentPlaylist: StateFlow<Playlist?>
|
||||
get() = _currentPlaylist
|
||||
|
||||
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentPlaylist] */
|
||||
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
|
||||
|
||||
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [playlistSongList] in the UI. */
|
||||
val playlistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistSongInstructions
|
||||
|
||||
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
||||
|
||||
/**
|
||||
* The new playlist songs created during the current editing session. Null if no editing session
|
||||
* is occurring.
|
||||
|
@ -204,54 +207,35 @@ constructor(
|
|||
playbackSettings.inParentPlaybackMode
|
||||
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
||||
|
||||
private val detailGenerator = detailGeneratorFactory.create(this)
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
detailGenerator.attach()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
detailGenerator.release()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
// If we are showing any item right now, we will need to refresh it (and any information
|
||||
// related to it) with the new library in order to prevent stale items from showing up
|
||||
// in the UI.
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
|
||||
logD("Updated song to ${currentSong.value}")
|
||||
override fun invalidate(type: MusicType, replace: Int?) {
|
||||
when (type) {
|
||||
MusicType.ALBUMS -> {
|
||||
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
|
||||
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
if (album != null) {
|
||||
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
||||
logD("Updated album to ${currentAlbum.value}")
|
||||
MusicType.ARTISTS -> {
|
||||
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
|
||||
refreshDetail(
|
||||
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
|
||||
}
|
||||
|
||||
val artist = currentArtist.value
|
||||
if (artist != null) {
|
||||
_currentArtist.value =
|
||||
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
||||
logD("Updated artist to ${currentArtist.value}")
|
||||
MusicType.GENRES -> {
|
||||
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
|
||||
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
|
||||
}
|
||||
|
||||
val genre = currentGenre.value
|
||||
if (genre != null) {
|
||||
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
|
||||
logD("Updated genre to ${currentGenre.value}")
|
||||
}
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
val playlist = currentPlaylist.value
|
||||
if (playlist != null) {
|
||||
_currentPlaylist.value =
|
||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||
logD("Updated playlist to ${currentPlaylist.value}")
|
||||
MusicType.PLAYLISTS -> {
|
||||
refreshPlaylist(currentPlaylist.value?.uid ?: return)
|
||||
}
|
||||
else -> error("Unexpected music type $type")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -328,23 +312,23 @@ constructor(
|
|||
private fun showImpl(show: Show) {
|
||||
val existing = toShow.flow.value
|
||||
if (existing != null) {
|
||||
logD("Already have pending show command $existing, ignoring $show")
|
||||
L.d("Already have pending show command $existing, ignoring $show")
|
||||
return
|
||||
}
|
||||
_toShow.put(show)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
|
||||
* be updated to align with the new [Song].
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
|
||||
* the new [Song].
|
||||
*
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSong(uid: Music.UID) {
|
||||
logD("Opening song $uid")
|
||||
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
||||
L.d("Opening song $uid")
|
||||
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
|
||||
if (_currentSong.value == null) {
|
||||
logW("Given song UID was invalid")
|
||||
L.w("Given song UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -355,11 +339,14 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||
*/
|
||||
fun setAlbum(uid: Music.UID) {
|
||||
logD("Opening album $uid")
|
||||
_currentAlbum.value =
|
||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
||||
L.d("Opening album $uid")
|
||||
if (uid === _currentAlbum.value?.uid) {
|
||||
return
|
||||
}
|
||||
val album = detailGenerator.album(uid)
|
||||
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
|
||||
if (_currentAlbum.value == null) {
|
||||
logW("Given album UID was invalid")
|
||||
L.w("Given album UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -370,7 +357,6 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSongSort(sort: Sort) {
|
||||
listSettings.albumSongSort = sort
|
||||
_currentAlbum.value?.let { refreshAlbumList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -380,12 +366,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||
*/
|
||||
fun setArtist(uid: Music.UID) {
|
||||
logD("Opening artist $uid")
|
||||
_currentArtist.value =
|
||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
||||
if (_currentArtist.value == null) {
|
||||
logW("Given artist UID was invalid")
|
||||
L.d("Opening artist $uid")
|
||||
if (uid === _currentArtist.value?.uid) {
|
||||
return
|
||||
}
|
||||
val artist = detailGenerator.artist(uid)
|
||||
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -395,7 +381,6 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSongSort(sort: Sort) {
|
||||
listSettings.artistSongSort = sort
|
||||
_currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -405,12 +390,12 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||
*/
|
||||
fun setGenre(uid: Music.UID) {
|
||||
logD("Opening genre $uid")
|
||||
_currentGenre.value =
|
||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
||||
if (_currentGenre.value == null) {
|
||||
logW("Given genre UID was invalid")
|
||||
L.d("Opening genre $uid")
|
||||
if (uid === _currentGenre.value?.uid) {
|
||||
return
|
||||
}
|
||||
val genre = detailGenerator.genre(uid)
|
||||
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -420,7 +405,6 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSongSort(sort: Sort) {
|
||||
listSettings.genreSongSort = sort
|
||||
_currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -430,20 +414,19 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
||||
*/
|
||||
fun setPlaylist(uid: Music.UID) {
|
||||
logD("Opening playlist $uid")
|
||||
_currentPlaylist.value =
|
||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
||||
if (_currentPlaylist.value == null) {
|
||||
logW("Given playlist UID was invalid")
|
||||
L.d("Opening playlist $uid")
|
||||
if (uid === _currentPlaylist.value?.uid) {
|
||||
return
|
||||
}
|
||||
refreshPlaylist(uid)
|
||||
}
|
||||
|
||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||
fun startPlaylistEdit() {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
logD("Starting playlist edit")
|
||||
L.d("Starting playlist edit")
|
||||
_editedPlaylist.value = playlist.songs
|
||||
refreshPlaylistList(playlist)
|
||||
refreshPlaylist(playlist.uid)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -453,12 +436,13 @@ constructor(
|
|||
fun savePlaylistEdit() {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = _editedPlaylist.value ?: return
|
||||
logD("Committing playlist edits")
|
||||
L.d("Committing playlist edits")
|
||||
viewModelScope.launch {
|
||||
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
||||
// TODO: The user could probably press some kind of button if they were fast enough.
|
||||
// Think of a better way to handle this state.
|
||||
_editedPlaylist.value = null
|
||||
refreshPlaylist(playlist.uid)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -474,9 +458,8 @@ constructor(
|
|||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
logD("Discarding playlist edits")
|
||||
_editedPlaylist.value = null
|
||||
refreshPlaylistList(playlist)
|
||||
refreshPlaylist(playlist.uid)
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -488,7 +471,7 @@ constructor(
|
|||
fun applyPlaylistSongSort(sort: Sort) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -501,15 +484,15 @@ constructor(
|
|||
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||
val playlist = _currentPlaylist.value ?: return false
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||
val realFrom = from - 2
|
||||
val realTo = to - 2
|
||||
val realFrom = from - 1
|
||||
val realTo = to - 1
|
||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||
return false
|
||||
}
|
||||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
L.d("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -521,204 +504,134 @@ constructor(
|
|||
fun removePlaylistSong(at: Int) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
||||
val realAt = at - 2
|
||||
val realAt = at - 1
|
||||
if (realAt !in editedPlaylist.indices) {
|
||||
return
|
||||
}
|
||||
logD("Removing playlist song at $realAt [$at]")
|
||||
L.d("Removing playlist song at $realAt [$at]")
|
||||
editedPlaylist.removeAt(realAt)
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylistList(
|
||||
playlist,
|
||||
refreshPlaylist(
|
||||
playlist.uid,
|
||||
if (editedPlaylist.isNotEmpty()) {
|
||||
UpdateInstructions.Remove(at, 1)
|
||||
} else {
|
||||
logD("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 2, 3)
|
||||
L.d("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 1, 3)
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshAudioInfo(song: Song) {
|
||||
logD("Refreshing audio info")
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_songAudioProperties.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = audioPropertiesFactory.extract(song)
|
||||
yield()
|
||||
logD("Updating audio info to $info")
|
||||
_songAudioProperties.value = info
|
||||
_currentSongProperties.value = buildList {
|
||||
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
|
||||
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
|
||||
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
|
||||
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
||||
logD("Refreshing album list")
|
||||
val list = mutableListOf<Item>()
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(header)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
song.disc?.let {
|
||||
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
|
||||
}
|
||||
|
||||
// To create a good user experience regarding disc numbers, we group the album's
|
||||
// songs up by disc and then delimit the groups by a disc header.
|
||||
val songs = albumSongSort.songs(album.songs)
|
||||
val byDisc = songs.groupBy { it.disc }
|
||||
if (byDisc.size > 1) {
|
||||
logD("Album has more than one disc, interspersing headers")
|
||||
for (entry in byDisc.entries) {
|
||||
list.add(DiscHeader(entry.key))
|
||||
list.addAll(entry.value)
|
||||
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
|
||||
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
|
||||
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
|
||||
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
|
||||
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
|
||||
song.replayGainAdjustment.track?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
|
||||
}
|
||||
song.replayGainAdjustment.album?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
|
||||
}
|
||||
} else {
|
||||
// Album only has one disc, don't add any redundant headers
|
||||
list.addAll(songs)
|
||||
}
|
||||
|
||||
logD("Update album list to ${list.size} items with $instructions")
|
||||
_albumSongInstructions.put(instructions)
|
||||
_albumSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
||||
logD("Refreshing artist list")
|
||||
val list = mutableListOf<Item>()
|
||||
|
||||
val grouping =
|
||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||
// Remap the complicated ReleaseType data structure into an easier
|
||||
// "AlbumGrouping" enum that will automatically group and sort
|
||||
// the artist's albums.
|
||||
when (it.releaseType.refinement) {
|
||||
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
|
||||
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
|
||||
null ->
|
||||
when (it.releaseType) {
|
||||
is ReleaseType.Album -> AlbumGrouping.ALBUMS
|
||||
is ReleaseType.EP -> AlbumGrouping.EPS
|
||||
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
||||
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
||||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
||||
is ReleaseType.Demo -> AlbumGrouping.DEMOS
|
||||
private inline fun <T : MusicParent> refreshDetail(
|
||||
detail: Detail<T>?,
|
||||
parent: MutableStateFlow<T?>,
|
||||
list: MutableStateFlow<List<Item>>,
|
||||
instructions: MutableEvent<UpdateInstructions>,
|
||||
replace: Int?,
|
||||
songHeader: (Int) -> PlainHeader = { SortHeader(it) }
|
||||
) {
|
||||
if (detail == null) {
|
||||
parent.value = null
|
||||
return
|
||||
}
|
||||
val newList = mutableListOf<Item>()
|
||||
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
for ((i, section) in detail.sections.withIndex()) {
|
||||
val items =
|
||||
when (section) {
|
||||
is DetailSection.PlainSection<*> -> {
|
||||
val header =
|
||||
if (section is DetailSection.Songs) songHeader(section.stringRes)
|
||||
else BasicHeader(section.stringRes)
|
||||
if (newList.isNotEmpty()) {
|
||||
newList.add(PlainDivider(header))
|
||||
}
|
||||
newList.add(header)
|
||||
section.items
|
||||
}
|
||||
is DetailSection.Discs -> {
|
||||
val header = SortHeader(section.stringRes)
|
||||
if (newList.isNotEmpty()) {
|
||||
newList.add(PlainDivider(header))
|
||||
}
|
||||
newList.add(header)
|
||||
buildList<Item> {
|
||||
for (entry in section.discs) {
|
||||
val discHeader = DiscHeader(inner = entry.key)
|
||||
if (isNotEmpty()) {
|
||||
add(DiscDivider(discHeader))
|
||||
}
|
||||
add(discHeader)
|
||||
addAll(entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artist.implicitAlbums.isNotEmpty()) {
|
||||
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
|
||||
// inherits list, we can cast upwards and save a copy by directly inserting the
|
||||
// implicit album list into the mapping.
|
||||
logD("Implicit albums present, adding to list")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(grouping as MutableMap<AlbumGrouping, Collection<Album>>)[AlbumGrouping.APPEARANCES] =
|
||||
artist.implicitAlbums
|
||||
}
|
||||
|
||||
logD("Release groups for this artist: ${grouping.keys}")
|
||||
|
||||
for ((i, entry) in grouping.entries.withIndex()) {
|
||||
val header = BasicHeader(entry.key.headerTitleRes)
|
||||
if (i > 0) {
|
||||
list.add(Divider(header))
|
||||
}
|
||||
list.add(header)
|
||||
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
|
||||
}
|
||||
|
||||
// Artists may not be linked to any songs, only include a header entry if we have any.
|
||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
logD("Songs present in this artist, adding header")
|
||||
val header = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
if (replace) {
|
||||
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||
// and thus need to be replaced.
|
||||
if (replace == -1 && i == detail.sections.lastIndex) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
instructions = UpdateInstructions.Replace(list.size)
|
||||
newInstructions = UpdateInstructions.Replace(newList.size)
|
||||
}
|
||||
list.addAll(artistSongSort.songs(artist.songs))
|
||||
newList.addAll(items)
|
||||
}
|
||||
|
||||
logD("Updating artist list to ${list.size} items with $instructions")
|
||||
_artistSongInstructions.put(instructions)
|
||||
_artistSongList.value = list.toList()
|
||||
parent.value = detail.parent
|
||||
instructions.put(newInstructions)
|
||||
list.value = newList
|
||||
}
|
||||
|
||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
||||
logD("Refreshing genre list")
|
||||
val list = mutableListOf<Item>()
|
||||
// Genre is guaranteed to always have artists and songs.
|
||||
val artistHeader = BasicHeader(R.string.lbl_artists)
|
||||
list.add(artistHeader)
|
||||
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||
|
||||
val songHeader = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(songHeader))
|
||||
list.add(songHeader)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced alongside the songs
|
||||
UpdateInstructions.Replace(list.size)
|
||||
} else {
|
||||
UpdateInstructions.Diff
|
||||
}
|
||||
list.addAll(genreSongSort.songs(genre.songs))
|
||||
|
||||
logD("Updating genre list to ${list.size} items with $instructions")
|
||||
_genreSongInstructions.put(instructions)
|
||||
_genreSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshPlaylistList(
|
||||
playlist: Playlist,
|
||||
private fun refreshPlaylist(
|
||||
uid: Music.UID,
|
||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
) {
|
||||
logD("Refreshing playlist list")
|
||||
L.d("Refreshing playlist list")
|
||||
val edited = editedPlaylist.value
|
||||
if (edited == null) {
|
||||
val playlist = detailGenerator.playlist(uid)
|
||||
refreshDetail(
|
||||
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) {
|
||||
EditHeader(it)
|
||||
}
|
||||
return
|
||||
}
|
||||
val list = mutableListOf<Item>()
|
||||
|
||||
val songs = editedPlaylist.value ?: playlist.songs
|
||||
if (songs.isNotEmpty()) {
|
||||
if (edited.isNotEmpty()) {
|
||||
val header = EditHeader(R.string.lbl_songs)
|
||||
list.add(header)
|
||||
list.addAll(songs)
|
||||
list.addAll(edited)
|
||||
}
|
||||
|
||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||
_playlistSongInstructions.put(instructions)
|
||||
_playlistSongList.value = list
|
||||
}
|
||||
|
||||
/**
|
||||
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
|
||||
*
|
||||
* @param headerTitleRes The title string resource to use for a header created out of an
|
||||
* instance of this enum.
|
||||
*/
|
||||
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
|
||||
ALBUMS(R.string.lbl_albums),
|
||||
EPS(R.string.lbl_eps),
|
||||
SINGLES(R.string.lbl_singles),
|
||||
COMPILATIONS(R.string.lbl_compilations),
|
||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||
DJMIXES(R.string.lbl_mixes),
|
||||
MIXTAPES(R.string.lbl_mixtapes),
|
||||
DEMOS(R.string.lbl_demos),
|
||||
APPEARANCES(R.string.lbl_appears_on),
|
||||
LIVE(R.string.lbl_live_group),
|
||||
REMIXES(R.string.lbl_remix_group),
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,21 +29,22 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
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.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information for a particular [Genre].
|
||||
|
@ -110,7 +111,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
|
||||
private fun updateGenre(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
logD("No genre to show, navigating away")
|
||||
L.d("No genre to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
@ -132,9 +133,17 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -144,7 +153,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
logD("Navigating to ${show.song}")
|
||||
L.d("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
@ -152,7 +161,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
// Songs should be scrolled to if the album matches, or a new detail
|
||||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -160,29 +169,29 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
// If the album matches, no need to do anything. Otherwise launch a new
|
||||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
L.d("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
L.d("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
logD("Navigated to this genre")
|
||||
L.d("Navigated to this genre")
|
||||
detailModel.toShow.consume()
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
|
@ -223,7 +232,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
GenreDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -262,7 +271,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")
|
||||
|
|
|
@ -35,13 +35,9 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
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.music.resolve
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||
|
@ -49,11 +45,15 @@ import org.oxycblt.auxio.util.collect
|
|||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information for a particular [Playlist].
|
||||
|
@ -82,11 +82,11 @@ class PlaylistDetailFragment :
|
|||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
logW("No URI returned from file picker")
|
||||
L.w("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
logD("Received playlist URI $uri")
|
||||
L.d("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||
}
|
||||
|
||||
|
@ -170,7 +170,8 @@ class PlaylistDetailFragment :
|
|||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(R.menu.playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
listModel.openMenu(
|
||||
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song) {
|
||||
|
@ -193,7 +194,7 @@ class PlaylistDetailFragment :
|
|||
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
logD("Binding edited playlist image")
|
||||
L.d("Binding edited playlist image")
|
||||
binding.detailCover.bind(
|
||||
editedPlaylist,
|
||||
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
|
@ -222,7 +223,7 @@ class PlaylistDetailFragment :
|
|||
|
||||
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
if (!playable) {
|
||||
logD("Playlist is being edited or is empty, disabling playback options")
|
||||
L.d("Playlist is being edited or is empty, disabling playback options")
|
||||
}
|
||||
|
||||
binding.detailPlayButton?.apply {
|
||||
|
@ -231,12 +232,26 @@ class PlaylistDetailFragment :
|
|||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarPlay.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailShuffleButton?.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarShuffle.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -248,7 +263,7 @@ class PlaylistDetailFragment :
|
|||
listModel.dropSelection()
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
logD("Updating save button state")
|
||||
L.d("Updating save button state")
|
||||
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
||||
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
||||
}
|
||||
|
@ -260,38 +275,38 @@ class PlaylistDetailFragment :
|
|||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
logD("Navigating to ${show.song}")
|
||||
L.d("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
L.d("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
L.d("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
logD("Navigated to this playlist")
|
||||
L.d("Navigated to this playlist")
|
||||
detailModel.toShow.consume()
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
|
@ -332,7 +347,7 @@ class PlaylistDetailFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Import -> {
|
||||
logD("Importing playlist")
|
||||
L.d("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
|
@ -342,7 +357,7 @@ class PlaylistDetailFragment :
|
|||
return
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
|
@ -350,15 +365,15 @@ class PlaylistDetailFragment :
|
|||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
logD("Exporting ${decision.playlist}")
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
L.d("Deleting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
PlaylistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -384,11 +399,11 @@ class PlaylistDetailFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
@ -399,15 +414,15 @@ class PlaylistDetailFragment :
|
|||
val id =
|
||||
when {
|
||||
detailModel.editedPlaylist.value != null -> {
|
||||
logD("Currently editing playlist, showing edit toolbar")
|
||||
L.d("Currently editing playlist, showing edit toolbar")
|
||||
R.id.detail_edit_toolbar
|
||||
}
|
||||
listModel.selected.value.isNotEmpty() -> {
|
||||
logD("Currently selecting, showing selection toolbar")
|
||||
L.d("Currently selecting, showing selection toolbar")
|
||||
R.id.detail_selection_toolbar
|
||||
}
|
||||
else -> {
|
||||
logD("Using normal toolbar")
|
||||
L.d("Using normal toolbar")
|
||||
R.id.detail_normal_toolbar
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
|
@ -32,17 +30,10 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
|||
import org.oxycblt.auxio.detail.list.SongProperty
|
||||
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.replaygain.formatDb
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.concatLocalized
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
|
||||
|
@ -71,74 +62,19 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSong(args.songUid)
|
||||
detailModel.toShow.consume()
|
||||
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
|
||||
collectImmediately(detailModel.currentSong, ::updateSong)
|
||||
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||
private fun updateSong(song: Song?) {
|
||||
L.d("No song to show, navigating away")
|
||||
if (song == null) {
|
||||
logD("No song to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
if (info != null) {
|
||||
val context = requireContext()
|
||||
detailAdapter.update(
|
||||
buildList {
|
||||
add(SongProperty(R.string.lbl_name, song.zipName(context)))
|
||||
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
|
||||
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
|
||||
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
|
||||
}
|
||||
song.disc?.let {
|
||||
val formattedNumber = getString(R.string.fmt_number, it.number)
|
||||
val zipped =
|
||||
if (it.name != null) {
|
||||
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
|
||||
} else {
|
||||
formattedNumber
|
||||
}
|
||||
add(SongProperty(R.string.lbl_disc, zipped))
|
||||
}
|
||||
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
|
||||
info.resolvedMimeType.resolveName(context)?.let {
|
||||
add(SongProperty(R.string.lbl_format, it))
|
||||
}
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
|
||||
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
|
||||
info.bitrateKbps?.let {
|
||||
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
|
||||
}
|
||||
info.sampleRateHz?.let {
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
|
||||
}
|
||||
song.replayGainAdjustment.track?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
|
||||
}
|
||||
song.replayGainAdjustment.album?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
|
||||
}
|
||||
},
|
||||
UpdateInstructions.Replace(0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Music> T.zipName(context: Context): String {
|
||||
val name = name
|
||||
return if (name is Name.Known && name.sort != null) {
|
||||
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
|
||||
} else {
|
||||
name.resolve(context)
|
||||
}
|
||||
private fun updateSongProperties(songProperties: List<SongProperty>) {
|
||||
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
private fun <T : Music> List<T>.zipNames(context: Context) =
|
||||
concatLocalized(context) { it.zipName(context) }
|
||||
}
|
||||
|
|
|
@ -25,9 +25,10 @@ import org.oxycblt.auxio.list.ClickableListListener
|
|||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Artist
|
||||
|
||||
/**
|
||||
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with
|
||||
|
|
|
@ -23,14 +23,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Library
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the
|
||||
|
@ -57,10 +56,10 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.deviceLibrary) return
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val library = musicRepository.library ?: return
|
||||
// Need to sanitize different items depending on the current set of choices.
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
|
||||
logD("Updated artist choices: ${_artistChoices.value}")
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(library)
|
||||
L.d("Updated artist choices: ${_artistChoices.value}")
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,20 +68,20 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
|
|||
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
||||
*/
|
||||
fun setArtistChoiceUid(itemUid: Music.UID) {
|
||||
logD("Opening navigation choices for $itemUid")
|
||||
L.d("Opening navigation choices for $itemUid")
|
||||
// Support Songs and Albums, which have parent artists.
|
||||
_artistChoices.value =
|
||||
when (val music = musicRepository.find(itemUid)) {
|
||||
is Song -> {
|
||||
logD("Creating navigation choices for song")
|
||||
L.d("Creating navigation choices for song")
|
||||
ArtistShowChoices.FromSong(music)
|
||||
}
|
||||
is Album -> {
|
||||
logD("Creating navigation choices for album")
|
||||
L.d("Creating navigation choices for album")
|
||||
ArtistShowChoices.FromAlbum(music)
|
||||
}
|
||||
else -> {
|
||||
logW("Given song/album UID was invalid")
|
||||
L.w("Given song/album UID was invalid")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
@ -99,16 +98,15 @@ sealed interface ArtistShowChoices {
|
|||
val uid: Music.UID
|
||||
/** The current [Artist] choices. */
|
||||
val choices: List<Artist>
|
||||
/** Sanitize this instance with a [DeviceLibrary]. */
|
||||
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
|
||||
/** Sanitize this instance with a [Library]. */
|
||||
fun sanitize(newLibrary: Library): ArtistShowChoices?
|
||||
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
|
||||
class FromSong(val song: Song) : ArtistShowChoices {
|
||||
override val uid = song.uid
|
||||
override val choices = song.artists
|
||||
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||
}
|
||||
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
|
||||
|
@ -116,7 +114,7 @@ sealed interface ArtistShowChoices {
|
|||
override val uid = album.uid
|
||||
override val choices = album.artists
|
||||
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
override fun sanitize(newLibrary: Library) =
|
||||
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,10 +32,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
|
||||
|
@ -85,7 +85,7 @@ class ShowArtistDialog :
|
|||
|
||||
private fun updateChoices(choices: ArtistShowChoices?) {
|
||||
if (choices == null) {
|
||||
logD("No choices to show, navigating away")
|
||||
L.d("No choices to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -24,21 +24,25 @@ import androidx.core.view.isGone
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDivider
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
|
||||
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.tag.Disc
|
||||
|
||||
/**
|
||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||
|
@ -52,6 +56,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
when (getItem(position)) {
|
||||
// Support sub-headers for each disc, and special album songs.
|
||||
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
||||
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
|
||||
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
@ -59,6 +64,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
|
||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
@ -79,6 +85,8 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
when {
|
||||
oldItem is Disc && newItem is Disc ->
|
||||
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is DiscDivider && newItem is DiscDivider ->
|
||||
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Song && newItem is Song ->
|
||||
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
|
||||
|
@ -94,7 +102,9 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class DiscHeader(val inner: Disc?) : Item
|
||||
data class DiscHeader(val inner: Disc?) : Header
|
||||
|
||||
data class DiscDivider(override val anchor: DiscHeader?) : Divider<DiscHeader>
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
|
||||
|
@ -111,16 +121,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
*/
|
||||
fun bind(discHeader: DiscHeader) {
|
||||
val disc = discHeader.inner
|
||||
if (disc != null) {
|
||||
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
||||
binding.discName.apply {
|
||||
text = disc.name
|
||||
isGone = disc.name == null
|
||||
}
|
||||
} else {
|
||||
logD("Disc is null, defaulting to no disc")
|
||||
binding.discNumber.text = binding.context.getString(R.string.def_disc)
|
||||
binding.discName.isGone = true
|
||||
binding.discNumber.text = disc.resolve(binding.context)
|
||||
binding.discName.apply {
|
||||
text = disc?.name
|
||||
isGone = disc?.name == null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,6 +150,42 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader]. Use [from] to create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DiscDividerViewHolder private constructor(divider: MaterialDivider) :
|
||||
RecyclerView.ViewHolder(divider) {
|
||||
|
||||
init {
|
||||
divider.dividerColor =
|
||||
divider.context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorOutlineVariant)
|
||||
.defaultColor
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_DIVIDER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) = DiscDividerViewHolder(MaterialDivider(parent.context))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<DiscDivider>() {
|
||||
override fun areContentsTheSame(oldItem: DiscDivider, newItem: DiscDivider) =
|
||||
oldItem.anchor == newItem.anchor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
|
||||
* create an instance.
|
||||
|
|
|
@ -29,12 +29,13 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
|
||||
|
@ -104,8 +105,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
|||
binding.parentName.text = album.name.resolve(binding.context)
|
||||
binding.parentInfo.text =
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
album.dates?.resolveDate(binding.context)
|
||||
?: binding.context.getString(R.string.def_date)
|
||||
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
|
|
|
@ -27,17 +27,17 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Music
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
|
||||
|
@ -55,7 +55,7 @@ abstract class DetailListAdapter(
|
|||
override fun getItemViewType(position: Int) =
|
||||
when (getItem(position)) {
|
||||
// Implement support for headers and sort headers
|
||||
is Divider -> DividerViewHolder.VIEW_TYPE
|
||||
is PlainDivider -> DividerViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
|
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
|
|||
object : SimpleDiffCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Divider && newItem is Divider ->
|
||||
oldItem is PlainDivider && newItem is PlainDivider ->
|
||||
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
|
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
|
|||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
||||
data class SortHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
|
||||
|
|
|
@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
|
||||
|
|
|
@ -33,20 +33,21 @@ import org.oxycblt.auxio.IntegerTable
|
|||
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
|
||||
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
||||
|
@ -97,9 +98,9 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating editing state [old: $isEditing new: $editing]")
|
||||
L.d("Updating editing state [old: $isEditing new: $editing]")
|
||||
this.isEditing = editing
|
||||
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
|
||||
notifyItemRangeChanged(0, currentList.size, PAYLOAD_EDITING_CHANGED)
|
||||
}
|
||||
|
||||
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
|
||||
|
@ -140,12 +141,12 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A [Header] variant that displays an edit button.
|
||||
* A [PlainHeader] variant that displays an edit button.
|
||||
*
|
||||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class EditHeader(@StringRes override val titleRes: Int) : Header
|
||||
data class EditHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
|
||||
/**
|
||||
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
|
||||
|
|
|
@ -18,17 +18,26 @@
|
|||
|
||||
package org.oxycblt.auxio.detail.list
|
||||
|
||||
import android.text.format.Formatter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.replaygain.formatDb
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.fs.Format
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
|
||||
/**
|
||||
* An adapter for [SongProperty] instances.
|
||||
|
@ -53,7 +62,31 @@ class SongPropertyAdapter :
|
|||
* @param value The value of the property.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SongProperty(@StringRes val name: Int, val value: String) : Item
|
||||
data class SongProperty(@StringRes val name: Int, val value: Value) {
|
||||
sealed interface Value {
|
||||
data class MusicName(val music: Music) : Value
|
||||
|
||||
data class MusicNames(val name: List<Music>) : Value
|
||||
|
||||
data class Number(val value: Int, val subtitle: String?) : Value
|
||||
|
||||
data class ItemDate(val date: Date) : Value
|
||||
|
||||
data class ItemPath(val path: Path) : Value
|
||||
|
||||
data class Size(val sizeBytes: Long) : Value
|
||||
|
||||
data class Duration(val durationMs: Long) : Value
|
||||
|
||||
data class ItemFormat(val format: Format) : Value
|
||||
|
||||
data class Bitrate(val kbps: Int) : Value
|
||||
|
||||
data class SampleRate(val hz: Int) : Value
|
||||
|
||||
data class Decibels(val value: Float) : Value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
|
||||
|
@ -65,7 +98,58 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
|
|||
fun bind(property: SongProperty) {
|
||||
val context = binding.context
|
||||
binding.propertyName.hint = context.getString(property.name)
|
||||
binding.propertyValue.setText(property.value)
|
||||
when (property.value) {
|
||||
is SongProperty.Value.MusicName -> {
|
||||
val music = property.value.music
|
||||
binding.propertyValue.setText(music.name.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.MusicNames -> {
|
||||
val names = property.value.name.resolveNames(context)
|
||||
binding.propertyValue.setText(names)
|
||||
}
|
||||
is SongProperty.Value.Number -> {
|
||||
val value = context.getString(R.string.fmt_number, property.value.value)
|
||||
val subtitle = property.value.subtitle
|
||||
binding.propertyValue.setText(
|
||||
if (subtitle != null) {
|
||||
context.getString(R.string.fmt_zipped_names, value, subtitle)
|
||||
} else {
|
||||
value
|
||||
})
|
||||
}
|
||||
is SongProperty.Value.ItemDate -> {
|
||||
val date = property.value.date
|
||||
binding.propertyValue.setText(date.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.ItemPath -> {
|
||||
val path = property.value.path
|
||||
binding.propertyValue.setText(path.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.Size -> {
|
||||
val size = property.value.sizeBytes
|
||||
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
|
||||
}
|
||||
is SongProperty.Value.Duration -> {
|
||||
val duration = property.value.durationMs
|
||||
binding.propertyValue.setText(duration.formatDurationMs(true))
|
||||
}
|
||||
is SongProperty.Value.ItemFormat -> {
|
||||
val format = property.value.format
|
||||
binding.propertyValue.setText(format.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.Bitrate -> {
|
||||
val kbps = property.value.kbps
|
||||
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
|
||||
}
|
||||
is SongProperty.Value.SampleRate -> {
|
||||
val hz = property.value.hz
|
||||
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
|
||||
}
|
||||
is SongProperty.Value.Decibels -> {
|
||||
val value = property.value.value
|
||||
binding.propertyValue.setText(value.formatDb(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Album
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
|
||||
|
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
logD("No album to sort, navigating away")
|
||||
L.d("No album to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
|
||||
|
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
logD("No artist to sort, navigating away")
|
||||
L.d("No artist to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Genre
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
|
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateGenre(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
logD("No genre to sort, navigating away")
|
||||
L.d("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
|
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updatePlaylist(genre: Playlist?) {
|
||||
if (genre == null) {
|
||||
logD("No genre to sort, navigating away")
|
||||
L.d("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,9 +24,11 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.openInBrowser
|
||||
|
@ -42,10 +44,12 @@ import org.oxycblt.auxio.util.showToast
|
|||
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
||||
private val args: ErrorDetailsDialogArgs by navArgs()
|
||||
private var clipboardManager: ClipboardManager? = null
|
||||
private val musicModel: MusicViewModel by viewModels()
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder
|
||||
.setTitle(R.string.lbl_error_info)
|
||||
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
|
||||
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
||||
requireContext().openInBrowser(LINK_ISSUES)
|
||||
}
|
||||
|
|
|
@ -22,10 +22,10 @@ import android.annotation.SuppressLint
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
|
@ -37,12 +37,10 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||
|
@ -53,34 +51,28 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
|
|||
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||
import org.oxycblt.auxio.home.list.PlaylistListFragment
|
||||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||
import org.oxycblt.auxio.home.tabs.NamedTabStrategy
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectionFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.NoAudioPermissionException
|
||||
import org.oxycblt.auxio.music.NoMusicException
|
||||
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
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
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.musikr.IndexingProgress
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||
|
@ -126,11 +118,11 @@ class HomeFragment :
|
|||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
logW("No URI returned from file picker")
|
||||
L.w("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
logD("Received playlist URI $uri")
|
||||
L.d("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||
}
|
||||
|
||||
|
@ -142,11 +134,6 @@ class HomeFragment :
|
|||
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||
}
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
// using a ColorStateList in the resources
|
||||
binding.homeIndexingProgress.trackColor =
|
||||
requireContext().getColorCompat(R.color.sel_track).defaultColor
|
||||
|
||||
binding.homePager.apply {
|
||||
// Update HomeViewModel whenever the user swipes through the ViewPager.
|
||||
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
|
||||
|
@ -185,6 +172,7 @@ class HomeFragment :
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
|
@ -221,17 +209,17 @@ class HomeFragment :
|
|||
return when (item.itemId) {
|
||||
// Handle main actions (Search, Settings, About)
|
||||
R.id.action_search -> {
|
||||
logD("Navigating to search")
|
||||
L.d("Navigating to search")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.search())
|
||||
true
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
logD("Navigating to preferences")
|
||||
L.d("Navigating to preferences")
|
||||
homeModel.showSettings()
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
logD("Navigating to about")
|
||||
L.d("Navigating to about")
|
||||
homeModel.showAbout()
|
||||
true
|
||||
}
|
||||
|
@ -251,7 +239,7 @@ class HomeFragment :
|
|||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
L.w("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
@ -265,7 +253,7 @@ class HomeFragment :
|
|||
if (homeModel.currentTabTypes.size == 1) {
|
||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||
// behavior.
|
||||
logD("Single tab shown, disabling TabLayout")
|
||||
L.d("Single tab shown, disabling TabLayout")
|
||||
binding.homeTabs.isVisible = false
|
||||
binding.homeAppbar.setExpanded(true, false)
|
||||
toolbarParams.scrollFlags = 0
|
||||
|
@ -278,9 +266,7 @@ class HomeFragment :
|
|||
|
||||
// Set up the mapping between the ViewPager and TabLayout.
|
||||
TabLayoutMediator(
|
||||
binding.homeTabs,
|
||||
binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
|
||||
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
|
||||
.attach()
|
||||
}
|
||||
|
||||
|
@ -303,7 +289,7 @@ class HomeFragment :
|
|||
private fun handleRecreate(recreate: Unit?) {
|
||||
if (recreate == null) return
|
||||
val binding = requireBinding()
|
||||
logD("Recreating ViewPager")
|
||||
L.d("Recreating ViewPager")
|
||||
// Move back to position zero, as there must be a tab there.
|
||||
binding.homePager.currentItem = 0
|
||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||
|
@ -311,99 +297,50 @@ class HomeFragment :
|
|||
homeModel.recreateTabs.consume()
|
||||
}
|
||||
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
// TODO: Make music loading experience a bit more pleasant
|
||||
// 1. Loading placeholder for item lists
|
||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||
null -> {
|
||||
logD("Indexer is in indeterminate state")
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
||||
if (error == null) {
|
||||
logD("Received ok response")
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
private fun handleChooseFolders(unit: Unit?) {
|
||||
if (unit == null) {
|
||||
return
|
||||
}
|
||||
|
||||
logD("Received non-ok response")
|
||||
val context = requireContext()
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
binding.homeIndexingActions.visibility = View.VISIBLE
|
||||
when (error) {
|
||||
is NoAudioPermissionException -> {
|
||||
logD("Showing permission prompt")
|
||||
binding.homeIndexingStatus.setText(R.string.err_no_perms)
|
||||
// Configure the action to act as a permission launcher.
|
||||
binding.homeIndexingTry.apply {
|
||||
text = context.getString(R.string.lbl_grant)
|
||||
setOnClickListener {
|
||||
requireNotNull(storagePermissionLauncher) {
|
||||
"Permission launcher was not available"
|
||||
}
|
||||
.launch(PERMISSION_READ_AUDIO)
|
||||
}
|
||||
}
|
||||
binding.homeIndexingMore.visibility = View.GONE
|
||||
}
|
||||
is NoMusicException -> {
|
||||
logD("Showing no music error")
|
||||
binding.homeIndexingStatus.setText(R.string.err_no_music)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingTry.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
binding.homeIndexingMore.visibility = View.GONE
|
||||
}
|
||||
else -> {
|
||||
logD("Showing generic error")
|
||||
binding.homeIndexingStatus.setText(R.string.err_index_failed)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingTry.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.rescan() }
|
||||
}
|
||||
binding.homeIndexingMore.apply {
|
||||
visibility = View.VISIBLE
|
||||
setOnClickListener {
|
||||
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
|
||||
homeModel.chooseMusicLocations.consume()
|
||||
}
|
||||
|
||||
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
||||
// Remove all content except for the progress indicator.
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||
binding.homeIndexingActions.visibility = View.INVISIBLE
|
||||
|
||||
binding.homeIndexingStatus.setText(R.string.lng_indexing)
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
// In a query/initialization state, show a generic loading status.
|
||||
binding.homeIndexingProgress.isIndeterminate = true
|
||||
}
|
||||
is IndexingProgress.Songs -> {
|
||||
// Actively loading songs, show the current progress.
|
||||
binding.homeIndexingProgress.apply {
|
||||
isIndeterminate = false
|
||||
max = progress.total
|
||||
this.progress = progress.current
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is IndexingState.Completed -> {
|
||||
binding.homeIndexingContainer.isInvisible = state.error == null
|
||||
binding.homeIndexingProgress.isInvisible = state.error != null
|
||||
binding.homeIndexingError.isInvisible = state.error == null
|
||||
if (state.error != null) {
|
||||
binding.homeIndexingContainer.setOnClickListener {
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.reportError(state.error))
|
||||
}
|
||||
} else {
|
||||
binding.homeIndexingContainer.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
is IndexingState.Indexing -> {
|
||||
binding.homeIndexingContainer.isInvisible = false
|
||||
binding.homeIndexingProgress.apply {
|
||||
isInvisible = false
|
||||
when (state.progress) {
|
||||
is IndexingProgress.Songs -> {
|
||||
isIndeterminate = false
|
||||
progress = state.progress.loaded
|
||||
max = state.progress.explored
|
||||
}
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
isIndeterminate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.homeIndexingError.isInvisible = true
|
||||
}
|
||||
null -> {
|
||||
binding.homeIndexingContainer.isInvisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -412,14 +349,14 @@ class HomeFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.New -> {
|
||||
logD("Creating new playlist")
|
||||
L.d("Creating new playlist")
|
||||
HomeFragmentDirections.newPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray(),
|
||||
decision.template,
|
||||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Import -> {
|
||||
logD("Importing playlist")
|
||||
L.d("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
|
@ -429,7 +366,7 @@ class HomeFragment :
|
|||
return
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
HomeFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
|
@ -437,15 +374,15 @@ class HomeFragment :
|
|||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
logD("Exporting ${decision.playlist}")
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
L.d("Deleting ${decision.playlist}")
|
||||
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} to a playlist")
|
||||
L.d("Adding ${decision.songs.size} to a playlist")
|
||||
HomeFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -476,38 +413,38 @@ class HomeFragment :
|
|||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
logD("Navigating to ${show.song}")
|
||||
L.d("Navigating to ${show.song}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
L.d("Navigating to ${show.album}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
L.d("Navigating to ${show.artist}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
logD("Navigating to ${show.genre}")
|
||||
L.d("Navigating to ${show.genre}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
logD("Navigating to ${show.playlist}")
|
||||
L.d("Navigating to ${show.playlist}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
|
||||
}
|
||||
|
@ -535,7 +472,7 @@ class HomeFragment :
|
|||
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
||||
// New selection started, show the AppBarLayout to indicate the new state.
|
||||
logD("Significant selection occurred, expanding AppBar")
|
||||
L.d("Significant selection occurred, expanding AppBar")
|
||||
binding.homeAppbar.expandWithScrollingRecycler()
|
||||
}
|
||||
} else {
|
||||
|
@ -571,11 +508,5 @@ class HomeFragment :
|
|||
private companion object {
|
||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
val FAB_HIDE_FROM_USER_FIELD: Method by
|
||||
lazyReflectedMethod(
|
||||
FloatingActionButton::class,
|
||||
"hide",
|
||||
FloatingActionButton.OnVisibilityChangedListener::class,
|
||||
Boolean::class)
|
||||
}
|
||||
}
|
||||
|
|
177
app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
Normal file
177
app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt
Normal file
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* HomeGenerator.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home
|
||||
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
interface HomeGenerator {
|
||||
fun attach()
|
||||
|
||||
fun release()
|
||||
|
||||
fun empty(): Boolean
|
||||
|
||||
fun songs(): List<Song>
|
||||
|
||||
fun albums(): List<Album>
|
||||
|
||||
fun artists(): List<Artist>
|
||||
|
||||
fun genres(): List<Genre>
|
||||
|
||||
fun playlists(): List<Playlist>
|
||||
|
||||
fun tabs(): List<MusicType>
|
||||
|
||||
interface Invalidator {
|
||||
fun invalidateEmpty() {}
|
||||
|
||||
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
|
||||
|
||||
fun invalidateTabs()
|
||||
}
|
||||
|
||||
interface Factory {
|
||||
fun create(invalidator: Invalidator): HomeGenerator
|
||||
}
|
||||
}
|
||||
|
||||
class HomeGeneratorFactoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val homeSettings: HomeSettings,
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
) : HomeGenerator.Factory {
|
||||
override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator =
|
||||
HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository)
|
||||
}
|
||||
|
||||
private class HomeGeneratorImpl(
|
||||
private val invalidator: HomeGenerator.Invalidator,
|
||||
private val homeSettings: HomeSettings,
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener {
|
||||
override fun attach() {
|
||||
homeSettings.registerListener(this)
|
||||
listSettings.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
invalidator.invalidateTabs()
|
||||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
L.d("Collaborator setting changed, forwarding update")
|
||||
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
|
||||
}
|
||||
|
||||
override fun onSongSortChanged() {
|
||||
super.onSongSortChanged()
|
||||
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
override fun onAlbumSortChanged() {
|
||||
super.onAlbumSortChanged()
|
||||
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
override fun onArtistSortChanged() {
|
||||
super.onArtistSortChanged()
|
||||
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
override fun onGenreSortChanged() {
|
||||
super.onGenreSortChanged()
|
||||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
override fun onPlaylistSortChanged() {
|
||||
super.onPlaylistSortChanged()
|
||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0))
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
invalidator.invalidateEmpty()
|
||||
|
||||
val library = musicRepository.library
|
||||
if (changes.deviceLibrary && library != null) {
|
||||
L.d("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
|
||||
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff)
|
||||
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
|
||||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
||||
}
|
||||
|
||||
if (changes.userLibrary && library != null) {
|
||||
L.d("Refreshing playlists")
|
||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
listSettings.unregisterListener(this)
|
||||
homeSettings.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun empty() = musicRepository.library?.empty() ?: true
|
||||
|
||||
override fun songs() =
|
||||
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||
|
||||
override fun albums() =
|
||||
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
|
||||
|
||||
override fun artists() =
|
||||
musicRepository.library?.let { deviceLibrary ->
|
||||
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
sorted.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
sorted
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun genres() =
|
||||
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
|
||||
|
||||
override fun playlists() =
|
||||
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||
?: emptyList()
|
||||
|
||||
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
}
|
|
@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent
|
|||
@InstallIn(SingletonComponent::class)
|
||||
interface HomeModule {
|
||||
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
|
||||
|
||||
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
|
||||
}
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* User configuration specific to the home UI.
|
||||
|
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
|||
|
||||
interface Listener {
|
||||
/** Called when the [homeTabs] configuration changes. */
|
||||
fun onTabsChanged()
|
||||
fun onTabsChanged() {}
|
||||
/** Called when the [shouldHideCollaborators] configuration changes. */
|
||||
fun onHideCollaboratorsChanged()
|
||||
fun onHideCollaboratorsChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
|
||||
override fun migrate() {
|
||||
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
||||
logD("Migrating tab setting")
|
||||
L.d("Migrating tab setting")
|
||||
val oldTabs =
|
||||
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
||||
logD("Old tabs: $oldTabs")
|
||||
L.d("Old tabs: $oldTabs")
|
||||
|
||||
// The playlist tab is now parsed, but it needs to be made visible.
|
||||
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
|
||||
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
|
||||
logD("New tabs: $oldTabs")
|
||||
L.d("New tabs: $oldTabs")
|
||||
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
||||
|
@ -90,11 +90,11 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||
when (key) {
|
||||
getString(R.string.set_key_home_tabs) -> {
|
||||
logD("Dispatching tab setting change")
|
||||
L.d("Dispatching tab setting change")
|
||||
listener.onTabsChanged()
|
||||
}
|
||||
getString(R.string.set_key_hide_collaborators) -> {
|
||||
logD("Dispatching collaborator setting change")
|
||||
L.d("Dispatching collaborator setting change")
|
||||
listener.onHideCollaboratorsChanged()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,18 +27,17 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
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
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* The ViewModel for managing the tab data and lists of the home view.
|
||||
|
@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
|
|||
class HomeViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val homeSettings: HomeSettings,
|
||||
private val listSettings: ListSettings,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
||||
|
||||
homeGeneratorFactory: HomeGenerator.Factory
|
||||
) : ViewModel(), HomeGenerator.Invalidator {
|
||||
private val _songList = MutableStateFlow(listOf<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val songList: StateFlow<List<Song>>
|
||||
|
@ -123,6 +120,10 @@ constructor(
|
|||
val playlistList: StateFlow<List<Playlist>>
|
||||
get() = _playlistList
|
||||
|
||||
private val _empty = MutableStateFlow(false)
|
||||
val empty: StateFlow<Boolean>
|
||||
get() = _empty
|
||||
|
||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genreList] in the UI. */
|
||||
val playlistInstructions: Event<UpdateInstructions>
|
||||
|
@ -132,11 +133,13 @@ constructor(
|
|||
val playlistSort: Sort
|
||||
get() = listSettings.playlistSort
|
||||
|
||||
private val homeGenerator = homeGeneratorFactory.create(this)
|
||||
|
||||
/**
|
||||
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
||||
* [Tab]s.
|
||||
*/
|
||||
var currentTabTypes = makeTabTypes()
|
||||
var currentTabTypes = homeGenerator.tabs()
|
||||
private set
|
||||
|
||||
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
||||
|
@ -160,64 +163,53 @@ constructor(
|
|||
val showOuter: Event<Outer>
|
||||
get() = _showOuter
|
||||
|
||||
private val _chooseMusicLocations = MutableEvent<Unit>()
|
||||
val chooseMusicLocations: Event<Unit>
|
||||
get() = _chooseMusicLocations
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
homeSettings.registerListener(this)
|
||||
homeGenerator.attach()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
homeSettings.unregisterListener(this)
|
||||
homeGenerator.release()
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
logD("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
_songInstructions.put(UpdateInstructions.Diff)
|
||||
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
|
||||
_albumInstructions.put(UpdateInstructions.Diff)
|
||||
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
|
||||
_artistInstructions.put(UpdateInstructions.Diff)
|
||||
_artistList.value =
|
||||
listSettings.artistSort.artists(
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
logD("Filtering collaborator artists")
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
logD("Using all artists")
|
||||
deviceLibrary.artists
|
||||
})
|
||||
_genreInstructions.put(UpdateInstructions.Diff)
|
||||
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
|
||||
}
|
||||
override fun invalidateEmpty() {
|
||||
_empty.value = homeGenerator.empty()
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
logD("Refreshing playlists")
|
||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||
when (type) {
|
||||
MusicType.SONGS -> {
|
||||
_songInstructions.put(instructions)
|
||||
_songList.value = homeGenerator.songs()
|
||||
}
|
||||
MusicType.ALBUMS -> {
|
||||
_albumInstructions.put(instructions)
|
||||
_albumList.value = homeGenerator.albums()
|
||||
}
|
||||
MusicType.ARTISTS -> {
|
||||
_artistInstructions.put(instructions)
|
||||
_artistList.value = homeGenerator.artists()
|
||||
}
|
||||
MusicType.GENRES -> {
|
||||
_genreInstructions.put(instructions)
|
||||
_genreList.value = homeGenerator.genres()
|
||||
}
|
||||
MusicType.PLAYLISTS -> {
|
||||
_playlistInstructions.put(instructions)
|
||||
_playlistList.value = homeGenerator.playlists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabTypes = makeTabTypes()
|
||||
logD("Updating tabs: ${currentTabType.value}")
|
||||
override fun invalidateTabs() {
|
||||
currentTabTypes = homeGenerator.tabs()
|
||||
_shouldRecreate.put(Unit)
|
||||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
logD("Collaborator setting changed, forwarding update")
|
||||
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [songList].
|
||||
*
|
||||
|
@ -225,8 +217,6 @@ constructor(
|
|||
*/
|
||||
fun applySongSort(sort: Sort) {
|
||||
listSettings.songSort = sort
|
||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,8 +226,6 @@ constructor(
|
|||
*/
|
||||
fun applyAlbumSort(sort: Sort) {
|
||||
listSettings.albumSort = sort
|
||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -247,8 +235,6 @@ constructor(
|
|||
*/
|
||||
fun applyArtistSort(sort: Sort) {
|
||||
listSettings.artistSort = sort
|
||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -258,8 +244,6 @@ constructor(
|
|||
*/
|
||||
fun applyGenreSort(sort: Sort) {
|
||||
listSettings.genreSort = sort
|
||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -269,8 +253,6 @@ constructor(
|
|||
*/
|
||||
fun applyPlaylistSort(sort: Sort) {
|
||||
listSettings.playlistSort = sort
|
||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -279,7 +261,7 @@ constructor(
|
|||
* @param pagerPos The new position of the ViewPager2 instance.
|
||||
*/
|
||||
fun synchronizeTabPosition(pagerPos: Int) {
|
||||
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||
L.d("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||
_currentTabType.value = currentTabTypes[pagerPos]
|
||||
}
|
||||
|
||||
|
@ -289,10 +271,14 @@ constructor(
|
|||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun setFastScrolling(isFastScrolling: Boolean) {
|
||||
logD("Updating fast scrolling state: $isFastScrolling")
|
||||
L.d("Updating fast scrolling state: $isFastScrolling")
|
||||
_isFastScrolling.value = isFastScrolling
|
||||
}
|
||||
|
||||
fun startChooseMusicLocations() {
|
||||
_chooseMusicLocations.put(Unit)
|
||||
}
|
||||
|
||||
fun showSettings() {
|
||||
_showOuter.put(Outer.Settings)
|
||||
}
|
||||
|
@ -300,15 +286,6 @@ constructor(
|
|||
fun showAbout() {
|
||||
_showOuter.put(Outer.About)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
|
||||
*
|
||||
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabTypes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
}
|
||||
|
||||
sealed interface Outer {
|
||||
|
|
|
@ -39,9 +39,6 @@ import androidx.core.os.BundleCompat
|
|||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.motion.MotionUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.leinardi.android.speeddial.FabWithLabelView
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
|
@ -49,6 +46,7 @@ import com.leinardi.android.speeddial.SpeedDialView
|
|||
import kotlin.math.roundToInt
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.AnimConfig
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
|
@ -80,12 +78,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
@AttrRes defStyleAttr: Int
|
||||
) : super(context, attrs, defStyleAttr)
|
||||
|
||||
private val matInterpolator =
|
||||
MotionUtils.resolveThemeInterpolator(
|
||||
context, MR.attr.motionEasingStandardInterpolator, FastOutSlowInInterpolator())
|
||||
|
||||
private val matDuration =
|
||||
MotionUtils.resolveThemeDuration(context, MR.attr.motionDurationMedium2, 300)
|
||||
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
|
||||
|
||||
init {
|
||||
// Work around ripple bug on Android 12 when useCompatPadding = true.
|
||||
|
@ -149,7 +142,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
}
|
||||
|
||||
private fun createMainFabAnimator(isOpen: Boolean): Animator {
|
||||
val totalDuration = matDuration.toLong()
|
||||
val totalDuration = stationaryConfig.duration
|
||||
val partialDuration = totalDuration / 2 // This is half of the total duration
|
||||
val delay = totalDuration / 4 // This is one fourth of the total duration
|
||||
|
||||
|
@ -181,7 +174,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
val animatorSet =
|
||||
AnimatorSet().apply {
|
||||
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
|
||||
interpolator = matInterpolator
|
||||
interpolator = stationaryConfig.interpolator
|
||||
}
|
||||
animatorSet.start()
|
||||
return animatorSet
|
||||
|
@ -197,6 +190,8 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
|
||||
overlayLayout.setBackgroundColor(overlayColor)
|
||||
}
|
||||
// Fix default margins added by library
|
||||
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private fun Int.withModulatedAlpha(
|
||||
|
@ -237,13 +232,24 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
return super.addActionItem(actionItem, position, animate)?.apply {
|
||||
fab.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
|
||||
setMargins(horizontalMargin, 0, horizontalMargin, 0)
|
||||
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
} else {
|
||||
setMargins(0, 0, rightMargin, 0)
|
||||
}
|
||||
}
|
||||
useCompatPadding = false
|
||||
}
|
||||
|
||||
labelBackground.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
}
|
||||
}
|
||||
useCompatPadding = false
|
||||
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
|
||||
background =
|
||||
|
@ -300,7 +306,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
|
||||
private val DRAWABLE_PROPERTY_LEVEL =
|
||||
object : Property<Drawable, Int>(Int::class.java, "level") {
|
||||
override fun get(drawable: Drawable): Int? = drawable.level
|
||||
override fun get(drawable: Drawable): Int = drawable.level
|
||||
|
||||
override fun set(drawable: Drawable, value: Int?) {
|
||||
drawable.level = value!!
|
||||
|
|
|
@ -1,185 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* FastScrollPopupView.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
|
||||
/**
|
||||
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
|
||||
*/
|
||||
class FastScrollPopupView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
|
||||
MaterialTextView(context, attrs, defStyleRes) {
|
||||
init {
|
||||
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_mid_huge)
|
||||
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_large)
|
||||
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
|
||||
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
|
||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
|
||||
alpha = 0f
|
||||
elevation = context.getDimenPixels(MR.dimen.m3_sys_elevation_level2).toFloat()
|
||||
background = FastScrollPopupDrawable(context)
|
||||
}
|
||||
|
||||
private class FastScrollPopupDrawable(context: Context) : Drawable() {
|
||||
private val paint: Paint =
|
||||
Paint().apply {
|
||||
isAntiAlias = true
|
||||
color =
|
||||
context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
|
||||
.defaultColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private val matrix = Matrix()
|
||||
|
||||
private val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
|
||||
private val paddingEnd = context.getDimenPixels(R.dimen.spacing_mid_huge)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
updatePath()
|
||||
}
|
||||
|
||||
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
|
||||
updatePath()
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOutline(outline: Outline) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
|
||||
|
||||
// Paths don't need to be convex on android Q, but the API was mislabeled and so
|
||||
// we still have to use this method.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
|
||||
else ->
|
||||
if (!path.isConvex) {
|
||||
// The outline path must be convex before Q, but we may run into floating
|
||||
// point errors caused by calculations involving sqrt(2) or OEM differences,
|
||||
// so in this case we just omit the shadow instead of crashing.
|
||||
super.getOutline(outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPadding(padding: Rect): Boolean {
|
||||
if (isRtl) {
|
||||
padding[paddingEnd, 0, paddingStart] = 0
|
||||
} else {
|
||||
padding[paddingStart, 0, paddingEnd] = 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun isAutoMirrored(): Boolean = true
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {}
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
private fun updatePath() {
|
||||
val r = bounds.height().toFloat() / 2
|
||||
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
|
||||
|
||||
path.apply {
|
||||
reset()
|
||||
|
||||
// Draw the left pill shape
|
||||
val o1X = w - SQRT2 * r
|
||||
arcToSafe(r, r, r, 90f, 180f)
|
||||
arcToSafe(o1X, r, r, -90f, 45f)
|
||||
|
||||
// Draw the right arrow shape
|
||||
val point = r / 5
|
||||
val o2X = w - SQRT2 * point
|
||||
arcToSafe(o2X, r, point, -45f, 90f)
|
||||
arcToSafe(o1X, r, r, 45f, 45f)
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
matrix.apply {
|
||||
reset()
|
||||
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
|
||||
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
|
||||
}
|
||||
|
||||
path.transform(matrix)
|
||||
}
|
||||
|
||||
private fun Path.arcToSafe(
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
radius: Float,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float
|
||||
) {
|
||||
arcTo(
|
||||
centerX - radius,
|
||||
centerY - radius,
|
||||
centerX + radius,
|
||||
centerY + radius,
|
||||
startAngle,
|
||||
sweepAngle,
|
||||
false)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Pre-calculate sqrt(2)
|
||||
const val SQRT2 = 1.4142135f
|
||||
}
|
||||
}
|
|
@ -22,6 +22,8 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Formatter
|
||||
|
@ -29,22 +31,23 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Album]s.
|
||||
|
@ -79,7 +82,16 @@ class AlbumListFragment :
|
|||
listener = this@AlbumListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_album_48)
|
||||
contentDescription = getString(R.string.lbl_albums)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.albumList, ::updateAlbums)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -99,10 +111,10 @@ class AlbumListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.albumSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> album.name.thumb
|
||||
is Sort.Mode.ByName -> album.name.thumb()
|
||||
|
||||
// By Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
|
||||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb()
|
||||
|
||||
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||
|
@ -115,7 +127,7 @@ class AlbumListFragment :
|
|||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = album.dateAdded.secsToMs()
|
||||
val dateAddedMillis = album.addedMs
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -147,6 +159,14 @@ class AlbumListFragment :
|
|||
albumAdapter.update(albums, homeModel.albumInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,28 +21,31 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.positiveOrNull
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Artist]s.
|
||||
|
@ -74,7 +77,16 @@ class ArtistListFragment :
|
|||
listener = this@ArtistListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_artist_48)
|
||||
contentDescription = getString(R.string.lbl_artists)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.artistList, ::updateArtists)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -94,7 +106,7 @@ class ArtistListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.artistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.name.thumb
|
||||
is Sort.Mode.ByName -> artist.name.thumb()
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||
|
@ -123,6 +135,14 @@ class ArtistListFragment :
|
|||
artistAdapter.update(artists, homeModel.artistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,27 +21,30 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Genre]s.
|
||||
|
@ -73,7 +76,16 @@ class GenreListFragment :
|
|||
listener = this@GenreListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_genre_48)
|
||||
contentDescription = getString(R.string.lbl_genres)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.genreList, ::updateGenres)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -93,7 +105,7 @@ class GenreListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.genreSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.name.thumb
|
||||
is Sort.Mode.ByName -> genre.name.thumb()
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||
|
@ -122,6 +134,14 @@ class GenreListFragment :
|
|||
genreAdapter.update(genres, homeModel.genreInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
31
app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt
Normal file
31
app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* ListUtil.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.list
|
||||
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.musikr.tag.Name
|
||||
|
||||
fun Name.thumb() =
|
||||
when (this) {
|
||||
is Name.Known ->
|
||||
tokens.firstOrNull()?.let {
|
||||
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
|
||||
}
|
||||
is Name.Unknown -> "?"
|
||||
}
|
|
@ -21,26 +21,29 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Playlist]s.
|
||||
|
@ -71,7 +74,18 @@ class PlaylistListFragment :
|
|||
listener = this@PlaylistListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_playlist_48)
|
||||
contentDescription = getString(R.string.lbl_playlists)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
|
||||
|
||||
collectImmediately(homeModel.playlistList, ::updatePlaylists)
|
||||
collectImmediately(
|
||||
homeModel.empty,
|
||||
homeModel.playlistList,
|
||||
musicModel.indexingState,
|
||||
::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -91,7 +105,7 @@ class PlaylistListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.playlistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> playlist.name.thumb
|
||||
is Sort.Mode.ByName -> playlist.name.thumb()
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
|
||||
|
@ -120,6 +134,26 @@ class PlaylistListFragment :
|
|||
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(
|
||||
empty: Boolean,
|
||||
playlists: List<Playlist>,
|
||||
indexingState: IndexingState?
|
||||
) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
|
||||
if (!empty && playlists.isEmpty()) {
|
||||
binding.homeNoMusicAction.isVisible = true
|
||||
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
|
||||
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
|
||||
} else {
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -22,27 +22,30 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Formatter
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Song]s.
|
||||
|
@ -59,6 +62,7 @@ class SongListFragment :
|
|||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val songAdapter = SongAdapter(this)
|
||||
|
||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||
private val formatterSb = StringBuilder(64)
|
||||
private val formatter = Formatter(formatterSb)
|
||||
|
@ -76,7 +80,16 @@ class SongListFragment :
|
|||
listener = this@SongListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_song_48)
|
||||
contentDescription = getString(R.string.lbl_songs)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.songList, ::updateSongs)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -98,23 +111,23 @@ class SongListFragment :
|
|||
// based off the names of the parent objects and not the child objects.
|
||||
return when (homeModel.songSort.mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.name.thumb
|
||||
is Sort.Mode.ByName -> song.name.thumb()
|
||||
|
||||
// Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
|
||||
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
|
||||
|
||||
// Album -> Use Album Name
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb()
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
|
||||
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext())
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = song.dateAdded.secsToMs()
|
||||
val dateAddedMillis = song.addedMs
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -146,6 +159,14 @@ class SongListFragment :
|
|||
songAdapter.update(songs, homeModel.songInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* AdaptiveTabStrategy.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
|
||||
/**
|
||||
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||
* depending on the screen configuration.
|
||||
*
|
||||
* @param context [Context] required to obtain window information
|
||||
* @param tabs Current tab configuration from settings
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val icon: Int
|
||||
val string: Int
|
||||
|
||||
when (tabs[position]) {
|
||||
MusicType.SONGS -> {
|
||||
icon = R.drawable.ic_song_24
|
||||
string = R.string.lbl_songs
|
||||
}
|
||||
MusicType.ALBUMS -> {
|
||||
icon = R.drawable.ic_album_24
|
||||
string = R.string.lbl_albums
|
||||
}
|
||||
MusicType.ARTISTS -> {
|
||||
icon = R.drawable.ic_artist_24
|
||||
string = R.string.lbl_artists
|
||||
}
|
||||
MusicType.GENRES -> {
|
||||
icon = R.drawable.ic_genre_24
|
||||
string = R.string.lbl_genres
|
||||
}
|
||||
MusicType.PLAYLISTS -> {
|
||||
icon = R.drawable.ic_playlist_24
|
||||
string = R.string.lbl_playlists
|
||||
}
|
||||
}
|
||||
|
||||
// Use expected sw* size thresholds when choosing a configuration.
|
||||
when {
|
||||
// On small screens, only display an icon.
|
||||
width < 370 -> tab.setIcon(icon).setContentDescription(string)
|
||||
// On large screens, display an icon and text.
|
||||
width < 600 -> tab.setText(string)
|
||||
// On medium-size screens, display text.
|
||||
else -> tab.setIcon(icon).setText(string)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* NamedTabStrategy.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
|
||||
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
tab.setText(homeTabs[position].nameRes)
|
||||
}
|
||||
}
|
|
@ -19,8 +19,7 @@
|
|||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A representation of a library tab suitable for configuration.
|
||||
|
@ -86,7 +85,7 @@ sealed class Tab(open val type: MusicType) {
|
|||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
L.w(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
|
@ -133,13 +132,13 @@ sealed class Tab(open val type: MusicType) {
|
|||
// Make sure there are no duplicate tabs
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
L.w(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
// For safety, return null if we have an empty or larger-than-expected tab array.
|
||||
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
||||
logE("Sequence size was ${distinct.size}, which is invalid")
|
||||
L.e("Sequence size was ${distinct.size}, which is invalid")
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
|
|||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||
|
@ -55,7 +55,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param newTabs The new array of tabs to show.
|
||||
*/
|
||||
fun submitTabs(newTabs: Array<Tab>) {
|
||||
logD("Force-updating tab information")
|
||||
L.d("Force-updating tab information")
|
||||
tabs = newTabs
|
||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param tab The new tab.
|
||||
*/
|
||||
fun setTab(at: Int, tab: Tab) {
|
||||
logD("Updating tab [at: $at, tab: $tab]")
|
||||
L.d("Updating tab [at: $at, tab: $tab]")
|
||||
tabs[at] = tab
|
||||
// Use a payload to avoid an item change animation.
|
||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
||||
|
@ -80,7 +80,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param b The position of the second tab to swap.
|
||||
*/
|
||||
fun swapTabs(a: Int, b: Int) {
|
||||
logD("Swapping tabs [a: $a, b: $b]")
|
||||
L.d("Swapping tabs [a: $a, b: $b]")
|
||||
val tmp = tabs[b]
|
||||
tabs[b] = tabs[a]
|
||||
tabs[a] = tmp
|
||||
|
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
|
|||
import org.oxycblt.auxio.home.HomeSettings
|
||||
import org.oxycblt.auxio.list.EditClickListListener
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
|
||||
|
@ -52,7 +52,7 @@ class TabCustomizeDialog :
|
|||
builder
|
||||
.setTitle(R.string.set_lib_tabs)
|
||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||
logD("Committing tab changes")
|
||||
L.d("Committing tab changes")
|
||||
homeSettings.homeTabs = tabAdapter.tabs
|
||||
}
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
|
@ -99,7 +99,7 @@ class TabCustomizeDialog :
|
|||
is Tab.Visible -> Tab.Invisible(old.type)
|
||||
is Tab.Invisible -> Tab.Visible(old.type)
|
||||
}
|
||||
logD("Flipping tab visibility [from: $old to: $new]")
|
||||
L.d("Flipping tab visibility [from: $old to: $new]")
|
||||
tabAdapter.setTab(index, new)
|
||||
|
||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
||||
|
|
|
@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import coil3.toBitmap
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A utility to provide bitmaps in a race-less manner.
|
||||
|
@ -94,7 +94,7 @@ constructor(
|
|||
target
|
||||
.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(listOf(song.cover))
|
||||
.data(song.cover)
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL))
|
||||
.target(
|
||||
|
|
|
@ -26,12 +26,11 @@ import org.oxycblt.auxio.IntegerTable
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class CoverMode {
|
||||
/** Do not load album covers ("Off"). */
|
||||
OFF,
|
||||
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
|
||||
MEDIA_STORE,
|
||||
/** Load high-quality covers directly from music files ("Quality"). */
|
||||
QUALITY;
|
||||
SAVE_SPACE,
|
||||
BALANCED,
|
||||
HIGH_QUALITY,
|
||||
AS_IS;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
|
@ -42,8 +41,10 @@ enum class CoverMode {
|
|||
get() =
|
||||
when (this) {
|
||||
OFF -> IntegerTable.COVER_MODE_OFF
|
||||
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
|
||||
QUALITY -> IntegerTable.COVER_MODE_QUALITY
|
||||
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
|
||||
BALANCED -> IntegerTable.COVER_MODE_BALANCED
|
||||
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
|
||||
AS_IS -> IntegerTable.COVER_MODE_AS_IS
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -57,8 +58,10 @@ enum class CoverMode {
|
|||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.COVER_MODE_OFF -> OFF
|
||||
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
|
||||
IntegerTable.COVER_MODE_QUALITY -> QUALITY
|
||||
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
|
||||
IntegerTable.COVER_MODE_BALANCED -> BALANCED
|
||||
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
|
||||
IntegerTable.COVER_MODE_AS_IS -> AS_IS
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
86
app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt
Normal file
86
app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* CoverProvider.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.UriMatcher
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
|
||||
class CoverProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
if (mode != "r" || uriMatcher.match(uri) != 1) {
|
||||
return null
|
||||
}
|
||||
val id = uri.lastPathSegment ?: return null
|
||||
return runBlocking {
|
||||
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
|
||||
is CoverResult.Hit -> result.cover.fd()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String {
|
||||
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
|
||||
return "image/*"
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor = throw UnsupportedOperationException()
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int = 0
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
|
||||
private const val IMAGES_PATH = "covers"
|
||||
private val uriMatcher =
|
||||
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
|
||||
|
||||
val CONTENT_URI: Uri =
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(IMAGES_PATH)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.image
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.animation.Animator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
|
@ -37,31 +37,35 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.core.view.updateMarginsRelative
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.target
|
||||
import coil3.request.transformations
|
||||
import coil3.util.CoilUtils
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
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
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
|
||||
import org.oxycblt.auxio.image.coil.SquareCropTransformation
|
||||
import org.oxycblt.auxio.ui.MaterialFader
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
/**
|
||||
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
|
||||
|
@ -93,7 +97,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private val selectionBadge: ImageView?
|
||||
private val iconSize: Int?
|
||||
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
private val fader = MaterialFader.quickLopsided(context)
|
||||
private var fadeAnimator: Animator? = null
|
||||
private val indicatorMatrix = Matrix()
|
||||
private val indicatorMatrixSrc = RectF()
|
||||
private val indicatorMatrixDst = RectF()
|
||||
|
@ -107,14 +112,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0)
|
||||
shapeAppearance =
|
||||
if (shapeAppearanceRes != 0) {
|
||||
ShapeAppearanceModel.builder(context, shapeAppearanceRes, -1).build()
|
||||
if (uiSettings.roundMode) {
|
||||
if (shapeAppearanceRes != 0) {
|
||||
ShapeAppearanceModel.builder(context, shapeAppearanceRes, -1).build()
|
||||
} else {
|
||||
ShapeAppearanceModel.builder(
|
||||
context,
|
||||
com.google.android.material.R.style
|
||||
.ShapeAppearance_Material3_Corner_Medium,
|
||||
-1)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
ShapeAppearanceModel.builder(
|
||||
context,
|
||||
com.google.android.material.R.style.ShapeAppearance_Material3_Corner_Medium,
|
||||
-1)
|
||||
.build()
|
||||
ShapeAppearanceModel.builder().build()
|
||||
}
|
||||
iconSize =
|
||||
styledAttrs.getDimensionPixelSize(R.styleable.CoverView_iconSize, -1).takeIf {
|
||||
|
@ -163,7 +173,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
super.onFinishInflate()
|
||||
|
||||
// The image isn't added if other children have populated the body. This is by design.
|
||||
if (childCount == 0) {
|
||||
if (isEmpty()) {
|
||||
addView(image)
|
||||
}
|
||||
|
||||
|
@ -294,43 +304,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
|
||||
// Set up a target transition for the selection indicator.
|
||||
val targetAlpha: Float
|
||||
val targetDuration: Long
|
||||
|
||||
if (isActivated) {
|
||||
// View is "activated" (i.e marked as selected), so show the selection indicator.
|
||||
targetAlpha = 1f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
// View is not "activated", hide the selection indicator.
|
||||
targetAlpha = 0f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
|
||||
if (selectionBadge.alpha == targetAlpha) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, initialize it without animation before drawing.
|
||||
selectionBadge.alpha = targetAlpha
|
||||
return
|
||||
}
|
||||
|
||||
if (fadeAnimator != null) {
|
||||
// Cancel any previous animation.
|
||||
fadeAnimator?.cancel()
|
||||
fadeAnimator = null
|
||||
}
|
||||
|
||||
fadeAnimator?.cancel()
|
||||
fadeAnimator =
|
||||
ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
|
||||
duration = targetDuration
|
||||
addUpdateListener { selectionBadge.alpha = it.animatedValue as Float }
|
||||
start()
|
||||
}
|
||||
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge))
|
||||
.also { it.start() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -340,7 +317,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(song: Song) =
|
||||
bindImpl(
|
||||
listOf(song.cover),
|
||||
song.cover,
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -351,7 +328,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(album: Album) =
|
||||
bindImpl(
|
||||
album.cover.all,
|
||||
album.covers,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -362,7 +339,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bindImpl(
|
||||
artist.cover.all,
|
||||
artist.covers,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
@ -373,7 +350,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bindImpl(
|
||||
genre.cover.all,
|
||||
genre.covers,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
@ -384,7 +361,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bindImpl(
|
||||
playlist.cover?.all ?: emptyList(),
|
||||
playlist.covers,
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
|
||||
|
@ -396,13 +373,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
||||
*/
|
||||
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
||||
bindImpl(Cover.order(songs), desc, errorRes)
|
||||
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
|
||||
|
||||
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
|
||||
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
|
||||
val request =
|
||||
ImageRequest.Builder(context)
|
||||
.data(covers)
|
||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
|
||||
.data(cover)
|
||||
.error(
|
||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||
.asImage())
|
||||
.target(image)
|
||||
|
||||
val cornersTransformation =
|
||||
|
@ -431,7 +410,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
@Px val iconSize: Int?
|
||||
) : Drawable() {
|
||||
init {
|
||||
// Re-tint the drawable to use the analogous "on surfaceg" color for
|
||||
// Re-tint the drawable to use the analogous "on surface" color for
|
||||
// StyledImageView.
|
||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* User configuration specific to image loading.
|
||||
|
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
get() =
|
||||
CoverMode.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
|
||||
?: CoverMode.MEDIA_STORE
|
||||
?: CoverMode.BALANCED
|
||||
|
||||
override val forceSquareCovers: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
|
||||
|
@ -58,14 +58,14 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
// Show album covers and Ignore MediaStore covers were unified in 3.0.0
|
||||
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
|
||||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
|
||||
logD("Migrating cover settings")
|
||||
L.d("Migrating cover settings")
|
||||
|
||||
val mode =
|
||||
when {
|
||||
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
|
||||
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
|
||||
CoverMode.MEDIA_STORE
|
||||
else -> CoverMode.QUALITY
|
||||
CoverMode.BALANCED
|
||||
else -> CoverMode.BALANCED
|
||||
}
|
||||
|
||||
sharedPreferences.edit {
|
||||
|
@ -74,12 +74,30 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
remove(OLD_KEY_QUALITY_COVERS)
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
|
||||
L.d("Migrating cover mode setting")
|
||||
|
||||
var mode =
|
||||
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
|
||||
?: CoverMode.BALANCED
|
||||
if (mode == CoverMode.HIGH_QUALITY) {
|
||||
// High quality now has space characteristics that could be
|
||||
// undesirable, clamp to balanced.
|
||||
mode = CoverMode.BALANCED
|
||||
}
|
||||
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
|
||||
remove(OLD_KEY_COVER_MODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||
if (key == getString(R.string.set_key_cover_mode) ||
|
||||
key == getString(R.string.set_key_square_covers)) {
|
||||
logD("Dispatching image setting change")
|
||||
L.d("Dispatching image setting change")
|
||||
listener.onImageSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
@ -87,5 +105,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
private companion object {
|
||||
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
|
||||
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
|
||||
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ExtractorModule.kt is part of Auxio.
|
||||
* CoilModule.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,11 +16,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import android.content.Context
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.transitionFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -30,19 +31,22 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class ExtractorModule {
|
||||
class CoilModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun imageLoader(
|
||||
@ApplicationContext context: Context,
|
||||
keyer: CoverKeyer,
|
||||
factory: CoverFetcher.Factory
|
||||
coverKeyer: CoverKeyer,
|
||||
coverFactory: CoverFetcher.Factory,
|
||||
coverCollectionKeyer: CoverCollectionKeyer,
|
||||
coverCollectionFactory: CoverCollectionFetcher.Factory
|
||||
) =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
// Add fetchers for Music components to make them usable with ImageRequest
|
||||
add(keyer)
|
||||
add(factory)
|
||||
add(coverKeyer)
|
||||
add(coverFactory)
|
||||
add(coverCollectionKeyer)
|
||||
add(coverCollectionFactory)
|
||||
}
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* CoverCollectionFetcher.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverCollectionFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val covers: CoverCollection,
|
||||
private val size: Size,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
|
||||
// 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.
|
||||
if (streams.size == 4) {
|
||||
// Make sure we free the InputStreams once we've transformed them into a
|
||||
// mosaic.
|
||||
return createMosaic(streams, size).also {
|
||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
||||
}
|
||||
}
|
||||
|
||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
||||
val first = streams.firstOrNull() ?: return null
|
||||
|
||||
// All but the first stream will be unused, free their resources
|
||||
withContext(Dispatchers.IO) {
|
||||
for (i in 1 until streams.size) {
|
||||
streams[i].close()
|
||||
}
|
||||
}
|
||||
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||
// Use whatever size coil gives us to create the mosaic.
|
||||
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
|
||||
val mosaicFrameSize =
|
||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||
|
||||
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
|
||||
val canvas = Canvas(mosaicBitmap)
|
||||
|
||||
var x = 0
|
||||
var y = 0
|
||||
|
||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
||||
// and place it on a corner of the canvas.
|
||||
for (stream in streams) {
|
||||
if (y == mosaicSize.height) {
|
||||
break
|
||||
}
|
||||
|
||||
// Crop the bitmap down to a square so it leaves no empty space
|
||||
// TODO: Work around this
|
||||
val bitmap =
|
||||
SquareCropTransformation.INSTANCE.transform(
|
||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||
|
||||
x += bitmap.width
|
||||
if (x == mosaicSize.width) {
|
||||
x = 0
|
||||
y += bitmap.height
|
||||
}
|
||||
}
|
||||
|
||||
// It's way easier to map this into a drawable then try to serialize it into an
|
||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
||||
// load low-res mosaics into high-res ImageViews.
|
||||
return ImageFetchResult(
|
||||
image = mosaicBitmap.toDrawable(context.resources).asImage(),
|
||||
isSampled = true,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
}
|
||||
|
||||
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
|
||||
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
|
||||
CoverCollectionFetcher(options.context, data, options.size)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* CoverFetcher.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import javax.inject.Inject
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
|
||||
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val stream = cover.open() ?: return null
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
|
||||
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data)
|
||||
}
|
||||
}
|
|
@ -16,15 +16,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import coil.decode.DataSource
|
||||
import coil.drawable.CrossfadeDrawable
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.transition.CrossfadeTransition
|
||||
import coil.transition.Transition
|
||||
import coil.transition.TransitionTarget
|
||||
import coil3.decode.DataSource
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.transition.CrossfadeDrawable
|
||||
import coil3.transition.CrossfadeTransition
|
||||
import coil3.transition.Transition
|
||||
import coil3.transition.TransitionTarget
|
||||
|
||||
/**
|
||||
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
|
34
app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt
Normal file
34
app/src/main/java/org/oxycblt/auxio/image/coil/Keyers.kt
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Keyers.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverKeyer @Inject constructor() : Keyer<Cover> {
|
||||
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
|
||||
}
|
||||
|
||||
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
|
||||
override fun key(data: CoverCollection, options: Options) =
|
||||
"multi:${data.hashCode()}&${options.size}"
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.createBitmap
|
||||
|
@ -30,16 +30,16 @@ import android.graphics.RectF
|
|||
import android.graphics.Shader
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
|
||||
* images without cropping them.
|
||||
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
|
||||
* without cropping them.
|
||||
*
|
||||
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -48,7 +48,7 @@ class RoundedRectTransformation(
|
|||
@Px private val topRight: Float = 0f,
|
||||
@Px private val bottomLeft: Float = 0f,
|
||||
@Px private val bottomRight: Float = 0f
|
||||
) : Transformation {
|
||||
) : Transformation() {
|
||||
|
||||
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
|
||||
|
||||
|
@ -65,7 +65,11 @@ class RoundedRectTransformation(
|
|||
|
||||
val (outputWidth, outputHeight) = calculateOutputSize(input, size)
|
||||
|
||||
val output = createBitmap(outputWidth, outputHeight, input.config)
|
||||
val output =
|
||||
createBitmap(
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
requireNotNull(input.config) { "unsupported bitmap format" })
|
||||
output.applyCanvas {
|
||||
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||
|
||||
|
@ -107,7 +111,10 @@ class RoundedRectTransformation(
|
|||
}
|
||||
|
||||
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
|
||||
// MODIFICATION: Remove short-circuiting for original size and input size
|
||||
if (size == Size.ORIGINAL) {
|
||||
// This path only runs w/the widget code, which already normalizes widget sizes
|
||||
return input.width to input.height
|
||||
}
|
||||
val multiplier =
|
||||
DecodeUtils.computeSizeMultiplier(
|
||||
srcWidth = input.width,
|
|
@ -16,12 +16,13 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
package org.oxycblt.auxio.image.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import androidx.core.graphics.scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
|
@ -30,7 +31,7 @@ import kotlin.math.min
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SquareCropTransformation : Transformation {
|
||||
class SquareCropTransformation : Transformation() {
|
||||
override val cacheKey: String
|
||||
get() = "SquareCropTransformation"
|
||||
|
||||
|
@ -46,7 +47,7 @@ class SquareCropTransformation : Transformation {
|
|||
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||
// Image is not the desired size, upscale it.
|
||||
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
||||
return dst.scale(desiredWidth, desiredHeight)
|
||||
}
|
||||
return dst
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* DeviceModule.kt is part of Auxio.
|
||||
* CoversModule.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,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.device
|
||||
package org.oxycblt.auxio.image.covers
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
@ -25,6 +25,6 @@ import dagger.hilt.components.SingletonComponent
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DeviceModule {
|
||||
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
||||
interface CoilModule {
|
||||
@Binds fun settingCovers(imageSettings: SettingCoversImpl): SettingCovers
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* NullCovers.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.covers
|
||||
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Metadata
|
||||
|
||||
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
|
||||
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
|
||||
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
|
||||
|
||||
override suspend fun cleanup(excluding: Collection<Cover>) {
|
||||
storage.ls(setOf()).map { storage.rm(it) }
|
||||
}
|
||||
}
|
||||
|
||||
data object NullCover : Cover {
|
||||
override val id = "null"
|
||||
|
||||
override suspend fun open() = null
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* RevisionedTranscoding.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.covers
|
||||
|
||||
import java.util.UUID
|
||||
import org.oxycblt.musikr.covers.stored.Transcoding
|
||||
|
||||
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
|
||||
override val tag = "_$revision${inner.tag}"
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* SettingCovers.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.covers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.FDCover
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.chained.ChainedCovers
|
||||
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
|
||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
||||
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
|
||||
import org.oxycblt.musikr.covers.fs.FSCovers
|
||||
import org.oxycblt.musikr.covers.fs.MutableFSCovers
|
||||
import org.oxycblt.musikr.covers.stored.Compress
|
||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
||||
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
|
||||
import org.oxycblt.musikr.covers.stored.NoTranscoding
|
||||
import org.oxycblt.musikr.covers.stored.StoredCovers
|
||||
|
||||
interface SettingCovers {
|
||||
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
|
||||
|
||||
companion object {
|
||||
suspend fun immutable(context: Context): Covers<FDCover> =
|
||||
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
|
||||
}
|
||||
}
|
||||
|
||||
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
SettingCovers {
|
||||
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
|
||||
val coverStorage = CoverStorage.at(context.coversDir())
|
||||
val transcoding =
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> return NullCovers(coverStorage)
|
||||
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
|
||||
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
|
||||
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
|
||||
CoverMode.AS_IS -> NoTranscoding
|
||||
}
|
||||
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
|
||||
val storedCovers =
|
||||
MutableStoredCovers(
|
||||
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
|
||||
val fsCovers = MutableFSCovers(context)
|
||||
return MutableChainedCovers(storedCovers, fsCovers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Components.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import coil.ImageLoader
|
||||
import coil.fetch.Fetcher
|
||||
import coil.key.Keyer
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import javax.inject.Inject
|
||||
|
||||
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
|
||||
override fun key(data: Collection<Cover>, options: Options) =
|
||||
"${data.map { it.key }.hashCode()}"
|
||||
}
|
||||
|
||||
class CoverFetcher
|
||||
private constructor(
|
||||
private val covers: Collection<Cover>,
|
||||
private val size: Size,
|
||||
private val coverExtractor: CoverExtractor,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch() = coverExtractor.extract(covers, size)
|
||||
|
||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Fetcher.Factory<Collection<Cover>> {
|
||||
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data, options.size, coverExtractor)
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* 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
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.net.Uri
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
sealed interface Cover {
|
||||
val key: String
|
||||
val mediaStoreCoverUri: Uri
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
|
||||
|
||||
fun order(songs: Collection<Song>) =
|
||||
FALLBACK_SORT.songs(songs)
|
||||
.map { it.cover }
|
||||
.groupBy { it.key }
|
||||
.entries
|
||||
.sortedByDescending { it.value.size }
|
||||
.map { it.value.first() }
|
||||
}
|
||||
}
|
||||
|
||||
data class ParentCover(val single: Cover, val all: List<Cover>) {
|
||||
companion object {
|
||||
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
|
||||
|
||||
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
|
||||
}
|
||||
}
|
|
@ -1,249 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CoverExtractor.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.media.MediaMetadataRetriever
|
||||
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
|
||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.SourceResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.guava.asDeferred
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* Provides functionality for extracting album cover information. Meant for internal use only.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class CoverExtractor
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val imageSettings: ImageSettings,
|
||||
private val mediaSourceFactory: MediaSource.Factory
|
||||
) {
|
||||
/**
|
||||
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
|
||||
*
|
||||
* @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(covers: Collection<Cover>, size: Size): FetchResult? {
|
||||
val streams = mutableListOf<InputStream>()
|
||||
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.
|
||||
if (streams.size == 4) {
|
||||
// Make sure we free the InputStreams once we've transformed them into a mosaic.
|
||||
return createMosaic(streams, size).also {
|
||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
||||
val first = streams.firstOrNull() ?: return null
|
||||
|
||||
// All but the first stream will be unused, free their resources
|
||||
withContext(Dispatchers.IO) {
|
||||
for (i in 1 until streams.size) {
|
||||
streams[i].close()
|
||||
}
|
||||
}
|
||||
|
||||
return SourceResult(
|
||||
source = ImageSource(first.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
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 {
|
||||
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.Embedded) =
|
||||
extractExoplayerCover(cover)
|
||||
?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
|
||||
|
||||
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
|
||||
setDataSource(context, cover.songUri)
|
||||
|
||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||
// ByteArray of the cover without any compression artifacts.
|
||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
|
||||
}
|
||||
|
||||
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
|
||||
val tracks =
|
||||
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
|
||||
.asDeferred()
|
||||
.await()
|
||||
|
||||
// The metadata extraction process of ExoPlayer results in a dump of all metadata
|
||||
// it found, which must be iterated through.
|
||||
val metadata = tracks[0].getFormat(0).metadata
|
||||
|
||||
if (metadata == null || metadata.length() == 0) {
|
||||
// No (parsable) metadata. This is also expected.
|
||||
return null
|
||||
}
|
||||
|
||||
return findCoverDataInMetadata(metadata)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||
// Use whatever size coil gives us to create the mosaic.
|
||||
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
||||
val mosaicFrameSize =
|
||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||
|
||||
val mosaicBitmap =
|
||||
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(mosaicBitmap)
|
||||
|
||||
var x = 0
|
||||
var y = 0
|
||||
|
||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
||||
// and place it on a corner of the canvas.
|
||||
for (stream in streams) {
|
||||
if (y == mosaicSize.height) {
|
||||
break
|
||||
}
|
||||
|
||||
// Crop the bitmap down to a square so it leaves no empty space
|
||||
// TODO: Work around this
|
||||
val bitmap =
|
||||
SquareCropTransformation.INSTANCE.transform(
|
||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||
|
||||
x += bitmap.width
|
||||
if (x == mosaicSize.width) {
|
||||
x = 0
|
||||
y += bitmap.height
|
||||
}
|
||||
}
|
||||
|
||||
// It's way easier to map this into a drawable then try to serialize it into an
|
||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
||||
// load low-res mosaics into high-res ImageViews.
|
||||
return DrawableResult(
|
||||
drawable = mosaicBitmap.toDrawable(context.resources),
|
||||
isSampled = true,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
@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)
|
||||
|
||||
// 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)
|
||||
}
|
|
@ -22,14 +22,16 @@ import androidx.annotation.StringRes
|
|||
|
||||
// TODO: Consider breaking this up into sealed classes for individual adapters
|
||||
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
||||
interface Item
|
||||
typealias Item = Any
|
||||
|
||||
interface Header
|
||||
|
||||
/**
|
||||
* A "header" used for delimiting groups of data.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface Header : Item {
|
||||
interface PlainHeader : Header {
|
||||
/** The string resource used for the header's title. */
|
||||
val titleRes: Int
|
||||
}
|
||||
|
@ -40,12 +42,16 @@ interface Header : Item {
|
|||
* @param titleRes The string resource used for the header's title.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : Header
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
|
||||
interface Divider<T> {
|
||||
val anchor: T?
|
||||
}
|
||||
|
||||
/**
|
||||
* A divider decoration used to delimit groups of data.
|
||||
*
|
||||
* @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
|
||||
* divider continuity during list updates.
|
||||
* @param anchor The [PlainHeader] this divider should be next to in a list. Used as a way to
|
||||
* preserve divider continuity during list updates.
|
||||
*/
|
||||
data class Divider(val anchor: Header?) : Item
|
||||
data class PlainDivider(override val anchor: PlainHeader?) : Divider<PlainHeader>
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.musikr.Music
|
||||
|
||||
/**
|
||||
* A Fragment containing a selectable list.
|
||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
||||
interface ListSettings : Settings<Unit> {
|
||||
interface ListSettings : Settings<ListSettings.Listener> {
|
||||
/** The [Sort] mode used in Song lists. */
|
||||
var songSort: Sort
|
||||
/** The [Sort] mode used in Album lists. */
|
||||
|
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
|
|||
var artistSongSort: Sort
|
||||
/** The [Sort] mode used in a Genre's Song list. */
|
||||
var genreSongSort: Sort
|
||||
|
||||
interface Listener {
|
||||
fun onSongSortChanged() {}
|
||||
|
||||
fun onAlbumSortChanged() {}
|
||||
|
||||
fun onAlbumSongSortChanged() {}
|
||||
|
||||
fun onArtistSortChanged() {}
|
||||
|
||||
fun onArtistSongSortChanged() {}
|
||||
|
||||
fun onGenreSortChanged() {}
|
||||
|
||||
fun onGenreSongSortChanged() {}
|
||||
|
||||
fun onPlaylistSortChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
||||
Settings.Impl<Unit>(context), ListSettings {
|
||||
Settings.Impl<ListSettings.Listener>(context), ListSettings {
|
||||
override var songSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
|
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
|
|||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
|
||||
when (key) {
|
||||
getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
|
||||
getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
|
||||
getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
|
||||
getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
|
||||
getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
|
||||
getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
|
||||
getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
|
||||
getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,19 +25,18 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
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.playback.PlaySong
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ViewModel] that orchestrates menu dialogs and selection state.
|
||||
|
@ -65,18 +64,17 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
val library = musicRepository.library ?: return
|
||||
// Sanitize the selection to remove items that no longer exist and thus
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
_selected.value.mapNotNull {
|
||||
when (it) {
|
||||
is Song -> deviceLibrary.findSong(it.uid)
|
||||
is Album -> deviceLibrary.findAlbum(it.uid)
|
||||
is Artist -> deviceLibrary.findArtist(it.uid)
|
||||
is Genre -> deviceLibrary.findGenre(it.uid)
|
||||
is Playlist -> userLibrary.findPlaylist(it.uid)
|
||||
is Song -> library.findSong(it.uid)
|
||||
is Album -> library.findAlbum(it.uid)
|
||||
is Artist -> library.findArtist(it.uid)
|
||||
is Genre -> library.findGenre(it.uid)
|
||||
is Playlist -> library.findPlaylist(it.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -94,16 +92,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
*/
|
||||
fun select(music: Music) {
|
||||
if (music is MusicParent && music.songs.isEmpty()) {
|
||||
logD("Cannot select empty parent, ignoring operation")
|
||||
L.d("Cannot select empty parent, ignoring operation")
|
||||
return
|
||||
}
|
||||
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
logD("Adding $music to selection")
|
||||
L.d("Adding $music to selection")
|
||||
selected.add(music)
|
||||
} else {
|
||||
logD("Removed $music from selection")
|
||||
L.d("Removed $music from selection")
|
||||
}
|
||||
|
||||
_selected.value = selected
|
||||
|
@ -131,7 +129,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun takeSelection(): List<Song> {
|
||||
logD("Taking selection")
|
||||
L.d("Taking selection")
|
||||
return peekSelection().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
|
@ -141,7 +139,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @return true if the prior selection was non-empty, false otherwise.
|
||||
*/
|
||||
fun dropSelection(): Boolean {
|
||||
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
L.d("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
|
@ -155,7 +153,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* should do.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
|
||||
logD("Opening menu for $song")
|
||||
L.d("Opening menu for $song")
|
||||
openImpl(Menu.ForSong(menuRes, song, playWith))
|
||||
}
|
||||
|
||||
|
@ -167,7 +165,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param album The [Album] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, album: Album) {
|
||||
logD("Opening menu for $album")
|
||||
L.d("Opening menu for $album")
|
||||
openImpl(Menu.ForAlbum(menuRes, album))
|
||||
}
|
||||
|
||||
|
@ -179,7 +177,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param artist The [Artist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
|
||||
logD("Opening menu for $artist")
|
||||
L.d("Opening menu for $artist")
|
||||
openImpl(Menu.ForArtist(menuRes, artist))
|
||||
}
|
||||
|
||||
|
@ -191,7 +189,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param genre The [Genre] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
|
||||
logD("Opening menu for $genre")
|
||||
L.d("Opening menu for $genre")
|
||||
openImpl(Menu.ForGenre(menuRes, genre))
|
||||
}
|
||||
|
||||
|
@ -203,7 +201,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param playlist The [Playlist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
|
||||
logD("Opening menu for $playlist")
|
||||
L.d("Opening menu for $playlist")
|
||||
openImpl(Menu.ForPlaylist(menuRes, playlist))
|
||||
}
|
||||
|
||||
|
@ -215,14 +213,14 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param songs The [Song] selection to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
|
||||
logD("Opening menu for ${songs.size} songs")
|
||||
L.d("Opening menu for ${songs.size} songs")
|
||||
openImpl(Menu.ForSelection(menuRes, songs))
|
||||
}
|
||||
|
||||
private fun openImpl(menu: Menu) {
|
||||
val existing = _menu.flow.value
|
||||
if (existing != null) {
|
||||
logW("Already opening $existing, ignoring $menu")
|
||||
L.w("Already opening $existing, ignoring $menu")
|
||||
return
|
||||
}
|
||||
_menu.put(menu)
|
||||
|
|
|
@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
|
|||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.concurrent.Executor
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A variant of ListDiffer with more flexible updates.
|
||||
|
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
instructions: UpdateInstructions?,
|
||||
callback: (() -> Unit)? = null
|
||||
) {
|
||||
logD("Updating list to ${newList.size} items with $instructions")
|
||||
L.d("Updating list to ${newList.size} items with $instructions")
|
||||
differ.update(newList, instructions, callback)
|
||||
}
|
||||
}
|
||||
|
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
|
|||
) {
|
||||
// fast simple remove all
|
||||
if (newList.isEmpty()) {
|
||||
logD("Short-circuiting diff to remove all")
|
||||
L.d("Short-circuiting diff to remove all")
|
||||
val countRemoved = oldList.size
|
||||
currentList = emptyList()
|
||||
// notify last, after list is updated
|
||||
|
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
// fast simple first insert
|
||||
if (oldList.isEmpty()) {
|
||||
logD("Short-circuiting diff to insert all")
|
||||
L.d("Short-circuiting diff to insert all")
|
||||
currentList = newList
|
||||
// notify last, after list is updated
|
||||
updateCallback.onInserted(0, newList.size)
|
||||
|
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
mainThreadExecutor.execute {
|
||||
if (maxScheduledGeneration == runGeneration) {
|
||||
logD("Applying calculated diff")
|
||||
L.d("Applying calculated diff")
|
||||
currentList = newList
|
||||
result.dispatchUpdatesTo(updateCallback)
|
||||
callback?.invoke()
|
||||
|
|
|
@ -21,8 +21,7 @@ package org.oxycblt.auxio.list.adapter
|
|||
import android.view.View
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
||||
|
@ -59,7 +58,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
* @param isPlaying Whether playback is ongoing or paused.
|
||||
*/
|
||||
fun setPlaying(item: T?, isPlaying: Boolean) {
|
||||
logD("Updating playing item [old: $currentItem new: $item]")
|
||||
L.d("Updating playing item [old: $currentItem new: $item]")
|
||||
|
||||
var updatedItem = false
|
||||
if (currentItem != item) {
|
||||
|
@ -72,7 +71,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logW("oldItem was not in adapter data")
|
||||
L.w("oldItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -82,7 +81,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logW("newItem was not in adapter data")
|
||||
L.w("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,7 +99,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logW("newItem was not in adapter data")
|
||||
L.w("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,8 +21,8 @@ package org.oxycblt.auxio.list.adapter
|
|||
import android.view.View
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.musikr.Music
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||
|
@ -55,7 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||
|
||||
selectedItems = newSelectedItems
|
||||
for (i in currentList.indices) {
|
||||
|
|
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
|
|||
import android.os.Parcelable
|
||||
import androidx.annotation.MenuRes
|
||||
import kotlinx.parcelize.Parcelize
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* Command to navigate to a specific menu dialog configuration.
|
||||
|
|
|
@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.ListViewModel
|
|||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of
|
||||
|
@ -102,7 +102,7 @@ abstract class MenuDialogFragment<M : Menu> :
|
|||
|
||||
private fun updateMenu(menu: Menu?) {
|
||||
if (menu == null) {
|
||||
logD("No menu to show, navigating away")
|
||||
L.d("No menu to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -27,17 +27,18 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Song].
|
||||
|
@ -112,7 +113,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
|
|||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.album)
|
||||
binding.menuType.text = getString(menu.album.releaseType.stringRes)
|
||||
binding.menuType.text = menu.album.releaseType.resolve(context)
|
||||
binding.menuName.text = menu.album.name.resolve(context)
|
||||
binding.menuInfo.text = menu.album.artists.resolveNames(context)
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
|
|||
oldItem == newItem
|
||||
|
||||
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
|
||||
oldItem.title == newItem.title
|
||||
oldItem.title.toString() == newItem.title.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,10 +23,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* Manages the state information for [MenuDialogFragment] implementations.
|
||||
|
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
|
|||
fun setMenu(parcel: Menu.Parcel) {
|
||||
_currentMenu.value = unpackParcel(parcel)
|
||||
if (_currentMenu.value == null) {
|
||||
logW("Given menu parcel $parcel was invalid")
|
||||
L.w("Given menu parcel $parcel was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
|
|||
}
|
||||
|
||||
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
|
||||
val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
|
||||
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null
|
||||
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
|
||||
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
|
||||
return Menu.ForSong(parcel.res, song, playWith)
|
||||
}
|
||||
|
||||
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
|
||||
val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
|
||||
val album = musicRepository.library?.findAlbum(parcel.albumUid) ?: return null
|
||||
return Menu.ForAlbum(parcel.res, album)
|
||||
}
|
||||
|
||||
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
|
||||
val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
|
||||
val artist = musicRepository.library?.findArtist(parcel.artistUid) ?: return null
|
||||
return Menu.ForArtist(parcel.res, artist)
|
||||
}
|
||||
|
||||
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
|
||||
val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
|
||||
val genre = musicRepository.library?.findGenre(parcel.genreUid) ?: return null
|
||||
return Menu.ForGenre(parcel.res, genre)
|
||||
}
|
||||
|
||||
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
|
||||
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
|
||||
val playlist = musicRepository.library?.findPlaylist(parcel.playlistUid) ?: return null
|
||||
return Menu.ForPlaylist(parcel.res, playlist)
|
||||
}
|
||||
|
||||
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
|
||||
val library = musicRepository.library ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(library::findSong)
|
||||
return Menu.ForSelection(parcel.res, songs)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
|
@ -38,6 +39,7 @@ open class AuxioRecyclerView
|
|||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
private val initialPaddingBottom = paddingBottom
|
||||
private var savedState: Parcelable? = null
|
||||
|
||||
init {
|
||||
// Prevent children from being clipped by window insets
|
||||
|
@ -60,6 +62,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// Update the RecyclerView's padding such that the bottom insets are applied
|
||||
// while still preserving bottom padding.
|
||||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
||||
if (savedState != null) {
|
||||
// State restore happens before we get insets, so there will be scroll drift unless
|
||||
// we restore the state after the insets are applied.
|
||||
// We must only do this once, otherwise we'll get jumpy behavior.
|
||||
super.onRestoreInstanceState(savedState)
|
||||
savedState = null
|
||||
}
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
super.onRestoreInstanceState(state)
|
||||
savedState = state
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,13 +16,18 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.animation.Animator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
|
@ -30,18 +35,22 @@ import android.view.ViewGroup
|
|||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.motion.MotionUtils
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||
import org.oxycblt.auxio.ui.MaterialFadingSlider
|
||||
import org.oxycblt.auxio.ui.MaterialSlider
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
@ -68,86 +77,69 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
* - Variable names are no longer prefixed with m
|
||||
* - Added drag listener
|
||||
* - Added documentation
|
||||
* - Completely new design
|
||||
* - New scroll position backend
|
||||
*
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Add vibration when popup changes
|
||||
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
|
||||
*/
|
||||
class FastScrollRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||
// Thumb
|
||||
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
|
||||
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
||||
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
||||
private var thumbAnimator: Animator? = null
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private val thumbView =
|
||||
View(context).apply {
|
||||
scaleX = 0f
|
||||
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
|
||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
|
||||
thumbSlider.jumpOut(this)
|
||||
}
|
||||
|
||||
private val thumbEnterInterpolator =
|
||||
MotionUtils.resolveThemeInterpolator(
|
||||
context,
|
||||
MR.attr.motionEasingEmphasizedDecelerateInterpolator,
|
||||
FastOutSlowInInterpolator())
|
||||
|
||||
private val thumbEnterDuration =
|
||||
MotionUtils.resolveThemeDuration(context, MR.attr.motionDurationShort4, 300)
|
||||
|
||||
private val thumbExitInterpolator =
|
||||
MotionUtils.resolveThemeInterpolator(
|
||||
context,
|
||||
MR.attr.motionEasingEmphasizedAccelerateInterpolator,
|
||||
FastOutSlowInInterpolator())
|
||||
|
||||
private val thumbExitDuration =
|
||||
MotionUtils.resolveThemeDuration(context, MR.attr.motionDurationShort2, 100)
|
||||
|
||||
private val thumbWidth = thumbView.background.intrinsicWidth
|
||||
private val thumbHeight = thumbView.background.intrinsicHeight
|
||||
private val thumbPadding = Rect(0, 0, 0, 0)
|
||||
private var thumbOffset = 0
|
||||
|
||||
private var showingThumb = false
|
||||
private val hideThumbRunnable = Runnable {
|
||||
if (!dragging) {
|
||||
hideScrollbar()
|
||||
hideThumb()
|
||||
}
|
||||
}
|
||||
|
||||
// Popup
|
||||
private val popupView =
|
||||
FastScrollPopupView(context).apply {
|
||||
scaleX = 0f
|
||||
scaleY = 0f
|
||||
alpha = 0f
|
||||
MaterialTextView(context).apply {
|
||||
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
|
||||
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
|
||||
setTextColor(
|
||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
|
||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
|
||||
elevation =
|
||||
context
|
||||
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
|
||||
.toFloat()
|
||||
background = context.getDrawableCompat(R.drawable.ui_popup)
|
||||
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
|
||||
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
|
||||
updatePaddingRelative(start = paddingStart, end = paddingEnd)
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
.apply {
|
||||
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||||
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
|
||||
}
|
||||
}
|
||||
|
||||
private val popupEnterInterpolator =
|
||||
MotionUtils.resolveThemeInterpolator(
|
||||
context,
|
||||
MR.attr.motionEasingEmphasizedDecelerateInterpolator,
|
||||
FastOutSlowInInterpolator())
|
||||
|
||||
private val popupEnterDuration =
|
||||
MotionUtils.resolveThemeDuration(context, MR.attr.motionDurationMedium1, 300)
|
||||
|
||||
private val popupExitInterpolator =
|
||||
MotionUtils.resolveThemeInterpolator(
|
||||
context,
|
||||
MR.attr.motionEasingEmphasizedAccelerateInterpolator,
|
||||
FastOutSlowInInterpolator())
|
||||
|
||||
private val popupExitDuration =
|
||||
MotionUtils.resolveThemeDuration(context, MR.attr.motionDurationShort2, 100)
|
||||
|
||||
private val popupSlider =
|
||||
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
|
||||
jumpOut(popupView)
|
||||
}
|
||||
private var popupAnimator: Animator? = null
|
||||
private var showingPopup = false
|
||||
|
||||
// Touch
|
||||
|
@ -160,6 +152,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private var dragStartY = 0f
|
||||
private var dragStartThumbOffset = 0
|
||||
|
||||
private var fastScrollingPossible = true
|
||||
|
||||
var fastScrollingEnabled = true
|
||||
set(value) {
|
||||
if (field == value) {
|
||||
return
|
||||
}
|
||||
|
||||
field = value
|
||||
if (!value) {
|
||||
removeCallbacks(hideThumbRunnable)
|
||||
hideThumb()
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
listener?.onFastScrollingChanged(field)
|
||||
}
|
||||
|
||||
private var dragging = false
|
||||
set(value) {
|
||||
if (field == value) {
|
||||
|
@ -179,15 +189,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
showScrollbar()
|
||||
showPopup()
|
||||
} else {
|
||||
postAutoHideScrollbar()
|
||||
hidePopup()
|
||||
postAutoHideScrollbar()
|
||||
}
|
||||
|
||||
listener?.onFastScrollingChanged(field)
|
||||
}
|
||||
|
||||
private val tRect = Rect()
|
||||
|
||||
var popupProvider: PopupProvider? = null
|
||||
var listener: Listener? = null
|
||||
|
||||
|
@ -222,22 +230,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// --- RECYCLERVIEW EVENT MANAGEMENT ---
|
||||
|
||||
private fun onPreDraw() {
|
||||
updateScrollbarState()
|
||||
updateThumbState()
|
||||
|
||||
thumbView.layoutDirection = layoutDirection
|
||||
popupView.layoutDirection = layoutDirection
|
||||
|
||||
thumbView.measure(
|
||||
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
|
||||
val thumbTop = thumbPadding.top + thumbOffset
|
||||
val thumbLeft =
|
||||
if (isRtl) {
|
||||
thumbPadding.left
|
||||
} else {
|
||||
width - thumbPadding.right - thumbWidth
|
||||
}
|
||||
|
||||
val thumbTop = thumbPadding.top + thumbOffset
|
||||
|
||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||
|
||||
popupView.layoutDirection = layoutDirection
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
if (child != null) {
|
||||
|
@ -254,10 +262,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
||||
} else {
|
||||
// No valid position or provider, do not show the popup.
|
||||
popupView.isInvisible = true
|
||||
popupView.isInvisible = false
|
||||
popupText = ""
|
||||
}
|
||||
|
||||
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
||||
|
||||
if (popupView.text != popupText) {
|
||||
|
@ -283,6 +290,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
popupLayoutParams.height)
|
||||
|
||||
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||
if (showingPopup) {
|
||||
doPopupVibration()
|
||||
}
|
||||
}
|
||||
|
||||
val popupWidth = popupView.measuredWidth
|
||||
|
@ -295,7 +305,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
val popupAnchorY = popupHeight / 2
|
||||
val thumbAnchorY = thumbView.paddingTop
|
||||
val thumbAnchorY = thumbView.height / 2
|
||||
|
||||
val popupTop =
|
||||
(thumbTop + thumbAnchorY - popupAnchorY)
|
||||
|
@ -309,7 +319,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
override fun onScrolled(dx: Int, dy: Int) {
|
||||
super.onScrolled(dx, dy)
|
||||
|
||||
updateScrollbarState()
|
||||
updateThumbState()
|
||||
|
||||
// Measure or layout events result in a fake onScrolled call. Ignore those.
|
||||
if (dx == 0 && dy == 0) {
|
||||
|
@ -327,30 +337,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return insets
|
||||
}
|
||||
|
||||
private fun updateScrollbarState() {
|
||||
if (scrollRange <= height || childCount == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Combine the previous item dimensions with the current item top to find our scroll
|
||||
// position
|
||||
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
|
||||
is LinearLayoutManager -> mgr.getPosition(child)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
|
||||
|
||||
private fun updateThumbState() {
|
||||
// Then calculate the thumb position, which is just:
|
||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
|
||||
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
|
||||
val offsetY = computeVerticalScrollOffset()
|
||||
if (computeVerticalScrollRange() < height || isEmpty()) {
|
||||
fastScrollingPossible = false
|
||||
hideThumb()
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
val extentY = computeVerticalScrollExtent()
|
||||
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY)
|
||||
thumbOffset = (thumbOffsetRange * fraction).toInt()
|
||||
}
|
||||
|
||||
private fun onItemTouch(event: MotionEvent): Boolean {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
dragging = false
|
||||
return false
|
||||
}
|
||||
val eventX = event.x
|
||||
val eventY = event.y
|
||||
|
||||
|
@ -364,10 +371,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
|
||||
dragStartThumbOffset = thumbOffset
|
||||
} else {
|
||||
} else if (eventX > thumbView.right - thumbWidth / 4) {
|
||||
dragStartThumbOffset =
|
||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
@ -404,44 +413,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun scrollToThumbOffset(thumbOffset: Int) {
|
||||
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
|
||||
val scrollOffset =
|
||||
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
|
||||
paddingTop
|
||||
|
||||
scrollTo(scrollOffset)
|
||||
}
|
||||
|
||||
private fun scrollTo(offset: Int) {
|
||||
if (childCount == 0) {
|
||||
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
|
||||
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
|
||||
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
|
||||
if (newOffsetY == 0f) {
|
||||
// Hacky workaround to drift in vertical scroll offset where we just snap
|
||||
// to the top if the thumb offset hit zero.
|
||||
scrollToPosition(0)
|
||||
return
|
||||
}
|
||||
|
||||
stopScroll()
|
||||
|
||||
val trueOffset = offset - paddingTop
|
||||
val itemHeight = itemHeight
|
||||
|
||||
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
|
||||
val firstItemTop = firstItemPosition * itemHeight - trueOffset
|
||||
|
||||
scrollToPositionWithOffset(firstItemPosition, firstItemTop)
|
||||
}
|
||||
|
||||
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
|
||||
var targetPosition = position
|
||||
val trueOffset = offset - paddingTop
|
||||
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> {
|
||||
targetPosition *= mgr.spanCount
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
is LinearLayoutManager -> {
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
}
|
||||
val dy = newOffsetY - previousOffsetY
|
||||
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
|
||||
}
|
||||
|
||||
// --- SCROLLBAR APPEARANCE ---
|
||||
|
@ -452,50 +436,39 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun showScrollbar() {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
return
|
||||
}
|
||||
if (showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = true
|
||||
thumbView
|
||||
.animate()
|
||||
.scaleX(1f)
|
||||
.setInterpolator(thumbEnterInterpolator)
|
||||
.setDuration(thumbEnterDuration.toLong())
|
||||
.start()
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
||||
}
|
||||
|
||||
private fun hideScrollbar() {
|
||||
private fun hideThumb() {
|
||||
if (!showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = false
|
||||
thumbView
|
||||
.animate()
|
||||
.scaleX(0f)
|
||||
.setInterpolator(thumbExitInterpolator)
|
||||
.setDuration(thumbExitDuration.toLong())
|
||||
.start()
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
return
|
||||
}
|
||||
if (showingPopup) {
|
||||
return
|
||||
}
|
||||
|
||||
popupView.scaleX = 0f
|
||||
popupView.scaleY = 0f
|
||||
|
||||
popupView.alpha = 1f
|
||||
showingPopup = true
|
||||
popupView
|
||||
.animate()
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.setInterpolator(popupEnterInterpolator)
|
||||
.setDuration(popupEnterDuration.toLong())
|
||||
.start()
|
||||
popupAnimator?.cancel()
|
||||
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
|
||||
}
|
||||
|
||||
private fun hidePopup() {
|
||||
|
@ -504,14 +477,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingPopup = false
|
||||
popupView
|
||||
.animate()
|
||||
.alpha(0f)
|
||||
.scaleX(0.75f)
|
||||
.scaleY(0.75f)
|
||||
.setInterpolator(popupExitInterpolator)
|
||||
.setDuration(popupExitDuration.toLong())
|
||||
.start()
|
||||
popupAnimator?.cancel()
|
||||
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
|
||||
}
|
||||
|
||||
private fun doPopupVibration() {
|
||||
performHapticFeedback(
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||
} else {
|
||||
HapticFeedbackConstants.KEYBOARD_TAP
|
||||
})
|
||||
}
|
||||
|
||||
// --- LAYOUT STATE ---
|
||||
|
@ -521,45 +497,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
|
||||
}
|
||||
|
||||
private val scrollRange: Int
|
||||
get() {
|
||||
val itemCount = itemCount
|
||||
|
||||
if (itemCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemHeight = itemHeight
|
||||
|
||||
return if (itemHeight != 0) {
|
||||
paddingTop + itemCount * itemHeight + paddingBottom
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private val scrollOffsetRange: Int
|
||||
get() = scrollRange - height
|
||||
|
||||
private val itemHeight: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemView = getChildAt(0)
|
||||
getDecoratedBoundsWithMargins(itemView, tRect)
|
||||
return tRect.height()
|
||||
}
|
||||
|
||||
private val itemCount: Int
|
||||
get() =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
|
||||
is LinearLayoutManager -> mgr.itemCount
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||
interface PopupProvider {
|
||||
/**
|
||||
|
@ -583,6 +520,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
|
||||
}
|
||||
}
|
|
@ -34,7 +34,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
|
||||
|
@ -92,9 +92,8 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
|
||||
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
|
||||
// this is only done once when the item is initially picked up.
|
||||
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
||||
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
logD("Lifting ViewHolder")
|
||||
L.d("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||
|
@ -136,7 +135,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
// This function can be called multiple times, so only start the animation when the view's
|
||||
// translationZ is already non-zero.
|
||||
if (holder.root.translationZ != 0f) {
|
||||
logD("Lifting ViewHolder")
|
||||
L.d("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDivider
|
||||
|
@ -27,20 +28,21 @@ import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
|||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.areNamesTheSame
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
||||
|
@ -360,7 +362,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [PlainDivider]. Use [from] to create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -381,8 +383,9 @@ class DividerViewHolder private constructor(divider: MaterialDivider) :
|
|||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<Divider>() {
|
||||
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
|
||||
object : SimpleDiffCallback<PlainDivider>() {
|
||||
@SuppressLint("DiffUtilEquals")
|
||||
override fun areContentsTheSame(oldItem: PlainDivider, newItem: PlainDivider) =
|
||||
oldItem.anchor == newItem.anchor
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
|
|||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A sorting method.
|
||||
|
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
|
||||
songs.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> songs.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
|
||||
Direction.ASCENDING -> songs.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
|
||||
}
|
||||
}
|
||||
|
||||
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
|
||||
albums.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> albums.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
|
||||
Direction.ASCENDING -> albums.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,17 +96,17 @@ abstract class SortDialog :
|
|||
|
||||
private fun updateButtons() {
|
||||
val binding = requireBinding()
|
||||
binding.sortSave.isEnabled = getCurrentSort() != getInitialSort()
|
||||
binding.sortSave.isEnabled = getCurrentSort().let { it != null && it != getInitialSort() }
|
||||
}
|
||||
|
||||
private fun getCurrentSort(): Sort? {
|
||||
val initial = getInitialSort()
|
||||
val mode = modeAdapter.currentMode ?: initial?.mode ?: return null
|
||||
val mode = modeAdapter.currentMode ?: return null
|
||||
val direction =
|
||||
when (requireBinding().sortDirectionGroup.checkedButtonId) {
|
||||
R.id.sort_direction_asc -> Sort.Direction.ASCENDING
|
||||
R.id.sort_direction_dsc -> Sort.Direction.DESCENDING
|
||||
else -> initial?.direction ?: return null
|
||||
else -> return null
|
||||
}
|
||||
return Sort(mode, direction)
|
||||
}
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Indexing.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/** Version-aware permission identifier for reading audio files. */
|
||||
val PERMISSION_READ_AUDIO =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.Manifest.permission.READ_MEDIA_AUDIO
|
||||
} else {
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current state of the music loader.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingState {
|
||||
/**
|
||||
* Music loading is on-going.
|
||||
*
|
||||
* @param progress The current progress of the music loading.
|
||||
*/
|
||||
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
*
|
||||
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||
* will be null.
|
||||
*/
|
||||
data class Completed(val error: Exception?) : IndexingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current progress of music loading.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingProgress {
|
||||
/** Other work is being done that does not have a defined progress. */
|
||||
data object Indeterminate : IndexingProgress
|
||||
|
||||
/**
|
||||
* Songs are currently being loaded.
|
||||
*
|
||||
* @param current The current amount of songs loaded.
|
||||
* @param total The projected total amount of songs.
|
||||
*/
|
||||
data class Songs(val current: Int, val total: Int) : IndexingProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoAudioPermissionException : Exception() {
|
||||
override val message = "Storage permissions are required to load music"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when no music was found.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoMusicException : Exception() {
|
||||
override val message = "No music was found on the device"
|
||||
}
|
|
@ -19,34 +19,31 @@
|
|||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.LinkedList
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.cache.CacheRepository
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.metadata.Separators
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
||||
import org.oxycblt.auxio.music.user.UserLibrary
|
||||
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
|
||||
import org.oxycblt.musikr.IndexingProgress
|
||||
import org.oxycblt.musikr.Interpretation
|
||||
import org.oxycblt.musikr.Library
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Musikr
|
||||
import org.oxycblt.musikr.MutableLibrary
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.Storage
|
||||
import org.oxycblt.musikr.cache.MutableCache
|
||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||
import org.oxycblt.musikr.tag.interpret.Naming
|
||||
import org.oxycblt.musikr.tag.interpret.Separators
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* Primary manager of music information and loading.
|
||||
|
@ -60,10 +57,9 @@ import org.oxycblt.auxio.util.logW
|
|||
* configurations
|
||||
*/
|
||||
interface MusicRepository {
|
||||
/** The current music information found on the device. */
|
||||
val deviceLibrary: DeviceLibrary?
|
||||
/** The current user-defined music information. */
|
||||
val userLibrary: UserLibrary?
|
||||
/** The current library */
|
||||
val library: Library?
|
||||
|
||||
/** The current state of music loading. Null if no load has occurred yet. */
|
||||
val indexingState: IndexingState?
|
||||
|
||||
|
@ -177,7 +173,7 @@ interface MusicRepository {
|
|||
* @param withCache Whether to load with the music cache or not.
|
||||
* @return The top-level music loading [Job] started.
|
||||
*/
|
||||
fun index(worker: IndexingWorker, withCache: Boolean): Job
|
||||
suspend fun index(worker: IndexingWorker, withCache: Boolean)
|
||||
|
||||
/** A listener for changes to the stored music information. */
|
||||
interface UpdateListener {
|
||||
|
@ -192,8 +188,8 @@ interface MusicRepository {
|
|||
/**
|
||||
* Flags indicating which kinds of music information changed.
|
||||
*
|
||||
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
|
||||
* @param userLibrary Whether the current [Playlist]s have changed.
|
||||
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
|
||||
* @param userLibrary Whether the current playlists have changed.
|
||||
*/
|
||||
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
||||
|
||||
|
@ -205,12 +201,6 @@ interface MusicRepository {
|
|||
|
||||
/** A persistent worker that can load music in the background. */
|
||||
interface IndexingWorker {
|
||||
/** A [Context] required to read device storage */
|
||||
val workerContext: Context
|
||||
|
||||
/** The [CoroutineScope] to perform coroutine music loading work on. */
|
||||
val scope: CoroutineScope
|
||||
|
||||
/**
|
||||
* Request that the music loading process ([index]) should be started. Any prior loads
|
||||
* should be canceled.
|
||||
|
@ -221,22 +211,42 @@ interface MusicRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current state of the music loader.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingState {
|
||||
/**
|
||||
* Music loading is on-going.
|
||||
*
|
||||
* @param progress The current progress of the music loading.
|
||||
*/
|
||||
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
*
|
||||
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||
* will be null.
|
||||
*/
|
||||
data class Completed(val error: Exception?) : IndexingState
|
||||
}
|
||||
|
||||
class MusicRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val cacheRepository: CacheRepository,
|
||||
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||
private val tagExtractor: TagExtractor,
|
||||
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
||||
private val userLibraryFactory: UserLibrary.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val cache: MutableCache,
|
||||
private val storedPlaylists: StoredPlaylists,
|
||||
private val settingCovers: SettingCovers,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MusicRepository {
|
||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
|
||||
@Volatile private var indexingWorker: IndexingWorker? = null
|
||||
|
||||
@Volatile override var deviceLibrary: DeviceLibrary? = null
|
||||
@Volatile override var userLibrary: MutableUserLibrary? = null
|
||||
@Volatile override var library: MutableLibrary? = null
|
||||
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
||||
@Volatile private var currentIndexingState: IndexingState? = null
|
||||
override val indexingState: IndexingState?
|
||||
|
@ -244,339 +254,158 @@ constructor(
|
|||
|
||||
@Synchronized
|
||||
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
logD("Adding $listener to update listeners")
|
||||
L.d("Adding $listener to update listeners")
|
||||
updateListeners.add(listener)
|
||||
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
logD("Removing $listener to update listeners")
|
||||
L.d("Removing $listener to update listeners")
|
||||
if (!updateListeners.remove(listener)) {
|
||||
logW("Update listener $listener was not added prior, cannot remove")
|
||||
L.w("Update listener $listener was not added prior, cannot remove")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
logD("Adding $listener to indexing listeners")
|
||||
L.d("Adding $listener to indexing listeners")
|
||||
indexingListeners.add(listener)
|
||||
listener.onIndexingStateChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
logD("Removing $listener from indexing listeners")
|
||||
L.d("Removing $listener from indexing listeners")
|
||||
if (!indexingListeners.remove(listener)) {
|
||||
logW("Indexing listener $listener was not added prior, cannot remove")
|
||||
L.w("Indexing listener $listener was not added prior, cannot remove")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||
override fun registerWorker(worker: IndexingWorker) {
|
||||
if (indexingWorker != null) {
|
||||
logW("Worker is already registered")
|
||||
L.w("Worker is already registered")
|
||||
return
|
||||
}
|
||||
logD("Registering worker $worker")
|
||||
L.d("Registering worker $worker")
|
||||
indexingWorker = worker
|
||||
if (indexingState == null) {
|
||||
worker.requestIndex(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||
override fun unregisterWorker(worker: IndexingWorker) {
|
||||
if (indexingWorker !== worker) {
|
||||
logW("Given worker did not match current worker")
|
||||
L.w("Given worker did not match current worker")
|
||||
return
|
||||
}
|
||||
logD("Unregistering worker $worker")
|
||||
L.d("Unregistering worker $worker")
|
||||
indexingWorker = null
|
||||
currentIndexingState = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun find(uid: Music.UID) =
|
||||
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
||||
?: userLibrary?.findPlaylist(uid))
|
||||
(library?.run {
|
||||
findSong(uid)
|
||||
?: findAlbum(uid)
|
||||
?: findArtist(uid)
|
||||
?: findGenre(uid)
|
||||
?: findPlaylist(uid)
|
||||
})
|
||||
|
||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Creating playlist $name with ${songs.size} songs")
|
||||
userLibrary.createPlaylist(name, songs)
|
||||
val library = synchronized(this) { library ?: return }
|
||||
L.d("Creating playlist $name with ${songs.size} songs")
|
||||
val newLibrary = library.createPlaylist(name, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
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)
|
||||
val library = synchronized(this) { library ?: return }
|
||||
L.d("Renaming $playlist to $name")
|
||||
val newLibrary = library.renamePlaylist(playlist, name)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
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)
|
||||
val library = synchronized(this) { library ?: return }
|
||||
L.d("Deleting $playlist")
|
||||
val newLibrary = library.deletePlaylist(playlist)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Adding ${songs.size} songs to $playlist")
|
||||
userLibrary.addToPlaylist(playlist, songs)
|
||||
val library = synchronized(this) { library ?: return }
|
||||
L.d("Adding ${songs.size} songs to $playlist")
|
||||
val newLibrary = library.addToPlaylist(playlist, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
logD("Rewriting $playlist with ${songs.size} songs")
|
||||
userLibrary.rewritePlaylist(playlist, songs)
|
||||
val library = synchronized(this) { library ?: return }
|
||||
L.d("Rewriting $playlist with ${songs.size} songs")
|
||||
val newLibrary = library.rewritePlaylist(playlist, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
logD("Requesting index operation [cache=$withCache]")
|
||||
L.d("Requesting index operation [cache=$withCache]")
|
||||
indexingWorker?.requestIndex(withCache)
|
||||
}
|
||||
|
||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
|
||||
|
||||
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
|
||||
L.d("Begin index [cache=$withCache]")
|
||||
try {
|
||||
indexImpl(context, scope, withCache)
|
||||
indexImpl(withCache)
|
||||
} catch (e: CancellationException) {
|
||||
// Got cancelled, propagate upwards to top-level co-routine.
|
||||
logD("Loading routine was cancelled")
|
||||
L.d("Loading routine was cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Music loading process failed due to something we have not handled.
|
||||
// TODO: Still want to display this error eventually
|
||||
logE("Music indexing failed")
|
||||
logE(e.stackTraceToString())
|
||||
L.e("Music indexing failed")
|
||||
L.e(e.stackTraceToString())
|
||||
emitIndexingCompletion(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||
// TODO: Find a way to break up this monster of a method, preferably as another class.
|
||||
|
||||
val start = System.currentTimeMillis()
|
||||
// Make sure we have permissions before going forward. Theoretically this would be better
|
||||
// done at the UI level, but that intertwines logic and display too much.
|
||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
logE("Permissions were not granted")
|
||||
throw NoAudioPermissionException()
|
||||
}
|
||||
|
||||
private suspend fun indexImpl(withCache: Boolean) {
|
||||
// Obtain configuration information
|
||||
val constraints =
|
||||
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
|
||||
val separators = Separators.from(musicSettings.separators)
|
||||
val nameFactory =
|
||||
if (musicSettings.intelligentSorting) {
|
||||
Name.Known.IntelligentFactory
|
||||
Naming.intelligent()
|
||||
} else {
|
||||
Name.Known.SimpleFactory
|
||||
Naming.simple()
|
||||
}
|
||||
val locations = musicSettings.musicLocations
|
||||
val withHidden = musicSettings.withHidden
|
||||
|
||||
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
|
||||
// to figure out what songs are (probably) on the device, and the latter will be needed
|
||||
// for discovery (described later). These have no shared state, so they are done in
|
||||
// parallel.
|
||||
logD("Starting MediaStore query")
|
||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||
|
||||
val mediaStoreQueryJob =
|
||||
scope.async {
|
||||
val query =
|
||||
try {
|
||||
mediaStoreExtractor.query(constraints)
|
||||
} catch (e: Exception) {
|
||||
// Normally, errors in an async call immediately bubble up to the Looper
|
||||
// and crash the app. Thus, we have to wrap any error into a Result
|
||||
// and then manually forward it to the try block that indexImpl is
|
||||
// called from.
|
||||
return@async Result.failure(e)
|
||||
}
|
||||
Result.success(query)
|
||||
}
|
||||
// Since this main thread is a co-routine, we can do operations in parallel in a way
|
||||
// identical to calling async.
|
||||
val cache =
|
||||
if (withCache) {
|
||||
logD("Reading cache")
|
||||
cacheRepository.readCache()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
logD("Awaiting MediaStore query")
|
||||
val query = mediaStoreQueryJob.await().getOrThrow()
|
||||
|
||||
// We now have all the information required to start the "discovery" process. This
|
||||
// is the point at which Auxio starts scanning each file given from MediaStore and
|
||||
// transforming it into a music library. MediaStore normally
|
||||
logD("Starting discovery")
|
||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
|
||||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
|
||||
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
|
||||
|
||||
// MediaStoreExtractor discovers all music on the device, and forwards them to either
|
||||
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
|
||||
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
|
||||
logD("Starting MediaStore discovery")
|
||||
val mediaStoreJob =
|
||||
scope.async {
|
||||
try {
|
||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||
} catch (e: Exception) {
|
||||
// To prevent a deadlock, we want to close the channel with an exception
|
||||
// to cascade to and cancel all other routines before finally bubbling up
|
||||
// to the main extractor loop.
|
||||
logE("MediaStore extraction failed: $e")
|
||||
incompleteSongs.close(
|
||||
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
|
||||
return@async
|
||||
}
|
||||
incompleteSongs.close()
|
||||
}
|
||||
|
||||
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
|
||||
// metadata for them, and then forwards it to DeviceLibrary.
|
||||
logD("Starting tag extraction")
|
||||
val tagJob =
|
||||
scope.async {
|
||||
try {
|
||||
tagExtractor.consume(incompleteSongs, completeSongs)
|
||||
} catch (e: Exception) {
|
||||
logE("Tag extraction failed: $e")
|
||||
completeSongs.close(
|
||||
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
|
||||
return@async
|
||||
}
|
||||
completeSongs.close()
|
||||
}
|
||||
|
||||
// DeviceLibrary constructs music parent instances as song information is provided,
|
||||
// and then forwards them to the primary loading loop.
|
||||
logD("Starting DeviceLibrary creation")
|
||||
val deviceLibraryJob =
|
||||
scope.async(Dispatchers.Default) {
|
||||
val deviceLibrary =
|
||||
try {
|
||||
deviceLibraryFactory.create(
|
||||
completeSongs, processedSongs, separators, nameFactory)
|
||||
} catch (e: Exception) {
|
||||
logE("DeviceLibrary creation failed: $e")
|
||||
processedSongs.close(
|
||||
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
|
||||
return@async Result.failure(e)
|
||||
}
|
||||
processedSongs.close()
|
||||
Result.success(deviceLibrary)
|
||||
}
|
||||
|
||||
// We could keep track of a total here, but we also need to collate this RawSong information
|
||||
// for when we write the cache later on in the finalization step.
|
||||
val rawSongs = LinkedList<RawSong>()
|
||||
// Use a longer timeout so that dependent components can timeout and throw errors that
|
||||
// provide more context than if we timed out here.
|
||||
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
|
||||
rawSongs.add(it)
|
||||
// Since discovery takes up the bulk of the music loading process, we switch to
|
||||
// indicating a defined amount of loaded songs in comparison to the projected amount
|
||||
// of songs that were queried.
|
||||
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
||||
}
|
||||
|
||||
withTimeout(DEFAULT_TIMEOUT) {
|
||||
mediaStoreJob.await()
|
||||
tagJob.await()
|
||||
}
|
||||
|
||||
// Deliberately done after the involved initialization step to make it less likely
|
||||
// that the short-circuit occurs so quickly as to break the UI.
|
||||
// TODO: Do not error, instead just wipe the entire library.
|
||||
if (rawSongs.isEmpty()) {
|
||||
logE("Music library was empty")
|
||||
throw NoMusicException()
|
||||
}
|
||||
|
||||
// Now that the library is effectively loaded, we can start the finalization step, which
|
||||
// involves writing new cache information and creating more music data that is derived
|
||||
// from the library (e.g playlists)
|
||||
logD("Discovered ${rawSongs.size} songs, starting finalization")
|
||||
|
||||
// We have no idea how long the cache will take, and the playlist construction
|
||||
// will be too fast to indicate, so switch back to an indeterminate state.
|
||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||
|
||||
// The UserLibrary job is split into a query and construction step, a la MediaStore.
|
||||
// This way, we can start working on playlists even as DeviceLibrary might still be
|
||||
// working on parent information.
|
||||
logD("Starting UserLibrary query")
|
||||
val userLibraryQueryJob =
|
||||
scope.async {
|
||||
val rawPlaylists =
|
||||
try {
|
||||
userLibraryFactory.query()
|
||||
} catch (e: Exception) {
|
||||
return@async Result.failure(e)
|
||||
}
|
||||
Result.success(rawPlaylists)
|
||||
}
|
||||
|
||||
// The cache might not exist, or we might have encountered a song not present in it.
|
||||
// Both situations require us to rewrite the cache in bulk. This is also done parallel
|
||||
// since the playlist read will probably take some time.
|
||||
// TODO: Read/write from the cache incrementally instead of in bulk?
|
||||
if (cache == null || cache.invalidated) {
|
||||
logD("Writing cache [why=${cache?.invalidated}]")
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
|
||||
// Create UserLibrary once we finally get the required components for it.
|
||||
logD("Awaiting UserLibrary query")
|
||||
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
|
||||
logD("Awaiting DeviceLibrary creation")
|
||||
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
||||
logD("Starting UserLibrary creation")
|
||||
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
|
||||
|
||||
// Loading process is functionally done, indicate such
|
||||
logD(
|
||||
"Successfully indexed music library [device=$deviceLibrary " +
|
||||
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
|
||||
val currentRevision = musicSettings.revision
|
||||
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
||||
val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
|
||||
val covers = settingCovers.mutate(context, newRevision)
|
||||
val storage = Storage(cache, covers, storedPlaylists)
|
||||
val interpretation = Interpretation(nameFactory, separators, withHidden)
|
||||
val result =
|
||||
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
||||
// Music loading completed, update the revision right now so we re-use this work
|
||||
// later.
|
||||
musicSettings.revision = newRevision
|
||||
// Deliver the library to the rest of the app
|
||||
// This will more or less block until all required item translation and
|
||||
// cleanup finishes.
|
||||
emitLibrary(result.library)
|
||||
// Clean up old data that is now impossible for the app to be using.
|
||||
result.cleanup()
|
||||
// Finish up loading.
|
||||
emitIndexingCompletion(null)
|
||||
|
||||
val deviceLibraryChanged: Boolean
|
||||
val userLibraryChanged: Boolean
|
||||
// We want to make sure that all reads and writes are synchronized due to the sheer
|
||||
// amount of consumers of MusicRepository.
|
||||
// TODO: Would Atomics not be a better fit here?
|
||||
synchronized(this) {
|
||||
// It's possible that this reload might have changed nothing, so make sure that
|
||||
// hasn't happened before dispatching a change to all consumers.
|
||||
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
|
||||
userLibraryChanged = this.userLibrary != userLibrary
|
||||
if (!deviceLibraryChanged && !userLibraryChanged) {
|
||||
logD("Library has not changed, skipping update")
|
||||
return
|
||||
}
|
||||
|
||||
this.deviceLibrary = deviceLibrary
|
||||
this.userLibrary = userLibrary
|
||||
}
|
||||
|
||||
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
||||
// so switch to it.
|
||||
withContext(Dispatchers.Main) {
|
||||
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
|
||||
|
@ -589,12 +418,45 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
|
||||
val deviceLibraryChanged: Boolean
|
||||
val userLibraryChanged: Boolean
|
||||
// We want to make sure that all reads and writes are synchronized due to the sheer
|
||||
// amount of consumers of MusicRepository.
|
||||
synchronized(this) {
|
||||
// It's possible that this reload might have changed nothing, so make sure that
|
||||
// hasn't happened before dispatching a change to all consumers.
|
||||
|
||||
// This is an old compat shim back when device library and user library were different
|
||||
// thinks. For the sake of avoiding drastic changes, it sticks around.
|
||||
// TODO: Remove this once you start work on kindred.
|
||||
deviceLibraryChanged =
|
||||
this.library?.songs != newLibrary.songs ||
|
||||
this.library?.albums != newLibrary.albums ||
|
||||
this.library?.artists != newLibrary.artists ||
|
||||
this.library?.genres != newLibrary.genres
|
||||
userLibraryChanged = this.library?.playlists != newLibrary.playlists
|
||||
if (!deviceLibraryChanged && !userLibraryChanged) {
|
||||
L.d("Library has not changed, skipping update")
|
||||
return
|
||||
}
|
||||
|
||||
this.library = newLibrary
|
||||
}
|
||||
|
||||
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
||||
// so switch to it.
|
||||
withContext(Dispatchers.Main) {
|
||||
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitIndexingCompletion(error: Exception?) {
|
||||
yield()
|
||||
synchronized(this) {
|
||||
previousCompletedState = IndexingState.Completed(error)
|
||||
currentIndexingState = null
|
||||
logD("Dispatching completion state [error=$error]")
|
||||
L.d("Dispatching completion state [error=$error]")
|
||||
for (listener in indexingListeners) {
|
||||
listener.onIndexingStateChanged()
|
||||
}
|
||||
|
@ -604,7 +466,7 @@ constructor(
|
|||
@Synchronized
|
||||
private fun dispatchLibraryChange(device: Boolean, user: Boolean) {
|
||||
val changes = MusicRepository.Changes(device, user)
|
||||
logD("Dispatching library change [changes=$changes]")
|
||||
L.d("Dispatching library change [changes=$changes]")
|
||||
for (listener in updateListeners) {
|
||||
listener.onMusicChanges(changes)
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue